Live video on the AT Protocol

oproxy: restructure

+960 -911
+1 -1
js/app/.env.development
··· 1 1 EXPO_PUBLIC_STREAMPLACE_URL=http://127.0.0.1:38080 2 - EXPO_PUBLIC_WEB_TRY_LOCAL=false 2 + EXPO_PUBLIC_WEB_TRY_LOCAL=true 3 3 EXPO_USE_METRO_WORKSPACE_ROOT=1
-180
pkg/oproxy/handlers.go
··· 1 - package oproxy 2 - 3 - import ( 4 - "encoding/json" 5 - "errors" 6 - "fmt" 7 - "net/http" 8 - 9 - "github.com/haileyok/atproto-oauth-golang/helpers" 10 - "github.com/labstack/echo/v4" 11 - "go.opentelemetry.io/otel" 12 - ) 13 - 14 - func (o *OProxy) Handler() http.Handler { 15 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 - w.Header().Set("Access-Control-Allow-Origin", "*") // todo: ehhhhhhhhhhhh 17 - w.Header().Set("Access-Control-Allow-Headers", "Content-Type,DPoP") 18 - w.Header().Set("Access-Control-Allow-Methods", "*") 19 - w.Header().Set("Access-Control-Expose-Headers", "DPoP-Nonce") 20 - o.e.ServeHTTP(w, r) 21 - }) 22 - } 23 - 24 - func (o *OProxy) HandleOAuthAuthorizationServer(c echo.Context) error { 25 - c.Response().Header().Set("Access-Control-Allow-Origin", "*") 26 - c.Response().Header().Set("Content-Type", "application/json") 27 - c.Response().WriteHeader(200) 28 - json.NewEncoder(c.Response().Writer).Encode(generateOAuthServerMetadata(o.host)) 29 - return nil 30 - } 31 - 32 - func (o *OProxy) HandleClientMetadataUpstream(c echo.Context) error { 33 - meta := o.GetUpstreamMetadata() 34 - return c.JSON(200, meta) 35 - } 36 - 37 - func (o *OProxy) HandleJwksUpstream(c echo.Context) error { 38 - pubKey, err := o.upstreamJWK.PublicKey() 39 - if err != nil { 40 - return echo.NewHTTPError(http.StatusInternalServerError, "could not get public key") 41 - } 42 - return c.JSON(200, helpers.CreateJwksResponseObject(pubKey)) 43 - } 44 - 45 - func (o *OProxy) HandleClientMetadataDownstream(c echo.Context) error { 46 - redirectURI := c.QueryParam("redirect_uri") 47 - meta, err := o.GetDownstreamMetadata(redirectURI) 48 - if err != nil { 49 - return err 50 - } 51 - return c.JSON(200, meta) 52 - } 53 - 54 - func (o *OProxy) HandleOAuthProtectedResource(c echo.Context) error { 55 - return c.JSON(200, map[string]interface{}{ 56 - "resource": fmt.Sprintf("https://%s", o.host), 57 - "authorization_servers": []string{ 58 - fmt.Sprintf("https://%s", o.host), 59 - }, 60 - "scopes_supported": []string{}, 61 - "bearer_methods_supported": []string{ 62 - "header", 63 - }, 64 - "resource_documentation": "https://atproto.com", 65 - }) 66 - } 67 - 68 - func (o *OProxy) HandleOAuthPAR(c echo.Context) error { 69 - ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthPAR") 70 - defer span.End() 71 - c.Response().Header().Set("Access-Control-Allow-Origin", "*") 72 - var par PAR 73 - if err := json.NewDecoder(c.Request().Body).Decode(&par); err != nil { 74 - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 75 - } 76 - 77 - dpopHeader := c.Request().Header.Get("DPoP") 78 - if dpopHeader == "" { 79 - return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") 80 - } 81 - 82 - resp, err := o.NewPAR(ctx, c, &par, dpopHeader) 83 - if errors.Is(err, ErrFirstNonce) { 84 - res := map[string]interface{}{ 85 - "error": "use_dpop_nonce", 86 - "error_description": "Authorization server requires nonce in DPoP proof", 87 - } 88 - return c.JSON(http.StatusBadRequest, res) 89 - } else if err != nil { 90 - return err 91 - } 92 - return c.JSON(http.StatusCreated, resp) 93 - } 94 - 95 - func (o *OProxy) HandleOAuthAuthorize(c echo.Context) error { 96 - ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthAuthorize") 97 - defer span.End() 98 - c.Response().Header().Set("Access-Control-Allow-Origin", "*") 99 - requestURI := c.QueryParam("request_uri") 100 - if requestURI == "" { 101 - return echo.NewHTTPError(http.StatusBadRequest, "request_uri is required") 102 - } 103 - clientID := c.QueryParam("client_id") 104 - if clientID == "" { 105 - return echo.NewHTTPError(http.StatusBadRequest, "client_id is required") 106 - } 107 - redirectURL, err := o.Authorize(ctx, requestURI, clientID) 108 - if err != nil { 109 - return err 110 - } 111 - return c.Redirect(http.StatusTemporaryRedirect, redirectURL) 112 - } 113 - 114 - func (o *OProxy) HandleOAuthReturn(c echo.Context) error { 115 - ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthReturn") 116 - defer span.End() 117 - code := c.QueryParam("code") 118 - iss := c.QueryParam("iss") 119 - state := c.QueryParam("state") 120 - redirectURL, err := o.Return(ctx, code, iss, state) 121 - if err != nil { 122 - return err 123 - } 124 - return c.Redirect(http.StatusTemporaryRedirect, redirectURL) 125 - } 126 - 127 - // TokenRequest represents the structure of an OAuth token request 128 - 129 - func (o *OProxy) HandleOAuthToken(c echo.Context) error { 130 - ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthToken") 131 - defer span.End() 132 - var tokenRequest TokenRequest 133 - if err := json.NewDecoder(c.Request().Body).Decode(&tokenRequest); err != nil { 134 - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request: %s", err)) 135 - } 136 - 137 - dpopHeader := c.Request().Header.Get("DPoP") 138 - if dpopHeader == "" { 139 - return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") 140 - } 141 - 142 - res, err := o.Token(ctx, &tokenRequest, dpopHeader) 143 - if err != nil { 144 - return err 145 - } 146 - jkt, _, err := getJKT(dpopHeader) 147 - if err != nil { 148 - return err 149 - } 150 - sess, err := o.loadOAuthSession(jkt) 151 - if err != nil { 152 - return err 153 - } 154 - sess.DownstreamDPoPNonce = makeNonce() 155 - err = o.updateOAuthSession(sess.DownstreamDPoPJKT, sess) 156 - if err != nil { 157 - return err 158 - } 159 - c.Response().Header().Set("DPoP-Nonce", sess.DownstreamDPoPNonce) 160 - 161 - return c.JSON(http.StatusOK, res) 162 - } 163 - 164 - func (o *OProxy) HandleOAuthRevoke(c echo.Context) error { 165 - ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthRevoke") 166 - defer span.End() 167 - var revokeRequest RevokeRequest 168 - if err := json.NewDecoder(c.Request().Body).Decode(&revokeRequest); err != nil { 169 - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request: %s", err)) 170 - } 171 - dpopHeader := c.Request().Header.Get("DPoP") 172 - if dpopHeader == "" { 173 - return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") 174 - } 175 - err := o.Revoke(ctx, dpopHeader, &revokeRequest) 176 - if err != nil { 177 - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("could not handle oauth revoke: %s", err)) 178 - } 179 - return c.JSON(http.StatusOK, map[string]interface{}{}) 180 - }
+155
pkg/oproxy/oauth_0_metadata.go
··· 1 + package oproxy 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "github.com/haileyok/atproto-oauth-golang/helpers" 9 + "github.com/labstack/echo/v4" 10 + ) 11 + 12 + func (o *OProxy) HandleOAuthAuthorizationServer(c echo.Context) error { 13 + c.Response().Header().Set("Access-Control-Allow-Origin", "*") 14 + c.Response().Header().Set("Content-Type", "application/json") 15 + c.Response().WriteHeader(200) 16 + json.NewEncoder(c.Response().Writer).Encode(generateOAuthServerMetadata(o.host)) 17 + return nil 18 + } 19 + 20 + func (o *OProxy) HandleOAuthProtectedResource(c echo.Context) error { 21 + return c.JSON(200, map[string]interface{}{ 22 + "resource": fmt.Sprintf("https://%s", o.host), 23 + "authorization_servers": []string{ 24 + fmt.Sprintf("https://%s", o.host), 25 + }, 26 + "scopes_supported": []string{}, 27 + "bearer_methods_supported": []string{ 28 + "header", 29 + }, 30 + "resource_documentation": "https://atproto.com", 31 + }) 32 + } 33 + 34 + func (o *OProxy) HandleClientMetadataUpstream(c echo.Context) error { 35 + meta := o.GetUpstreamMetadata() 36 + return c.JSON(200, meta) 37 + } 38 + 39 + func (o *OProxy) HandleJwksUpstream(c echo.Context) error { 40 + pubKey, err := o.upstreamJWK.PublicKey() 41 + if err != nil { 42 + return echo.NewHTTPError(http.StatusInternalServerError, "could not get public key") 43 + } 44 + return c.JSON(200, helpers.CreateJwksResponseObject(pubKey)) 45 + } 46 + 47 + func (o *OProxy) HandleClientMetadataDownstream(c echo.Context) error { 48 + redirectURI := c.QueryParam("redirect_uri") 49 + meta, err := o.GetDownstreamMetadata(redirectURI) 50 + if err != nil { 51 + return err 52 + } 53 + return c.JSON(200, meta) 54 + } 55 + 56 + func (o *OProxy) GetUpstreamMetadata() *OAuthClientMetadata { 57 + // publicKey, err := o.upstreamJWK.PublicKey() 58 + // if err != nil { 59 + // panic(err) 60 + // } 61 + // jwks := jwk.NewSet() 62 + // err = jwks.AddKey(publicKey) 63 + // if err != nil { 64 + // panic(err) 65 + // } 66 + // ro := helpers.CreateJwksResponseObject(publicKey) 67 + meta := &OAuthClientMetadata{ 68 + ClientID: fmt.Sprintf("https://%s/oauth/upstream/client-metadata.json", o.host), 69 + JwksURI: fmt.Sprintf("https://%s/oauth/upstream/jwks.json", o.host), 70 + ClientURI: fmt.Sprintf("https://%s", o.host), 71 + // RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)}, 72 + Scope: "atproto transition:generic", 73 + TokenEndpointAuthMethod: "private_key_jwt", 74 + ClientName: "Streamplace", 75 + ResponseTypes: []string{"code"}, 76 + GrantTypes: []string{"authorization_code", "refresh_token"}, 77 + DPoPBoundAccessTokens: boolPtr(true), 78 + TokenEndpointAuthSigningAlg: "ES256", 79 + RedirectURIs: []string{fmt.Sprintf("https://%s/oauth/return", o.host)}, 80 + // Jwks: ro, 81 + } 82 + return meta 83 + } 84 + 85 + func generateOAuthServerMetadata(host string) map[string]any { 86 + oauthServerMetadata := map[string]any{ 87 + "issuer": fmt.Sprintf("https://%s", host), 88 + "request_parameter_supported": true, 89 + "request_uri_parameter_supported": true, 90 + "require_request_uri_registration": true, 91 + "scopes_supported": []string{"atproto", "transition:generic", "transition:chat.bsky"}, 92 + "subject_types_supported": []string{"public"}, 93 + "response_types_supported": []string{"code"}, 94 + "response_modes_supported": []string{"query", "fragment", "form_post"}, 95 + "grant_types_supported": []string{"authorization_code", "refresh_token"}, 96 + "code_challenge_methods_supported": []string{"S256"}, 97 + "ui_locales_supported": []string{"en-US"}, 98 + "display_values_supported": []string{"page", "popup", "touch"}, 99 + "authorization_response_iss_parameter_supported": true, 100 + "request_object_encryption_alg_values_supported": []string{}, 101 + "request_object_encryption_enc_values_supported": []string{}, 102 + "jwks_uri": fmt.Sprintf("https://%s/oauth/jwks", host), 103 + "authorization_endpoint": fmt.Sprintf("https://%s/oauth/authorize", host), 104 + "token_endpoint": fmt.Sprintf("https://%s/oauth/token", host), 105 + "token_endpoint_auth_methods_supported": []string{"none", "private_key_jwt"}, 106 + "revocation_endpoint": fmt.Sprintf("https://%s/oauth/revoke", host), 107 + "introspection_endpoint": fmt.Sprintf("https://%s/oauth/introspect", host), 108 + "pushed_authorization_request_endpoint": fmt.Sprintf("https://%s/oauth/par", host), 109 + "require_pushed_authorization_requests": true, 110 + "client_id_metadata_document_supported": true, 111 + "request_object_signing_alg_values_supported": []string{ 112 + "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", 113 + "ES256", "ES256K", "ES384", "ES512", "none", 114 + }, 115 + "token_endpoint_auth_signing_alg_values_supported": []string{ 116 + "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", 117 + "ES256", "ES256K", "ES384", "ES512", 118 + }, 119 + "dpop_signing_alg_values_supported": []string{ 120 + "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", 121 + "ES256", "ES256K", "ES384", "ES512", 122 + }, 123 + } 124 + return oauthServerMetadata 125 + } 126 + 127 + func (o *OProxy) GetDownstreamMetadata(redirectURI string) (*OAuthClientMetadata, error) { 128 + meta := &OAuthClientMetadata{ 129 + ClientID: fmt.Sprintf("https://%s/oauth/downstream/client-metadata.json", o.host), 130 + ClientURI: fmt.Sprintf("https://%s", o.host), 131 + // RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)}, 132 + Scope: "atproto transition:generic", 133 + TokenEndpointAuthMethod: "none", 134 + ClientName: "Streamplace", 135 + ResponseTypes: []string{"code"}, 136 + GrantTypes: []string{"authorization_code", "refresh_token"}, 137 + DPoPBoundAccessTokens: boolPtr(true), 138 + RedirectURIs: []string{fmt.Sprintf("https://%s/login", o.host), fmt.Sprintf("https://%s/api/app-return", o.host)}, 139 + ApplicationType: "web", 140 + } 141 + if redirectURI != "" { 142 + found := false 143 + for _, uri := range meta.RedirectURIs { 144 + if uri == redirectURI { 145 + found = true 146 + break 147 + } 148 + } 149 + if !found { 150 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s not in allowed URIs", redirectURI)) 151 + } 152 + meta.RedirectURIs = []string{redirectURI} 153 + } 154 + return meta, nil 155 + }
+178
pkg/oproxy/oauth_1_par.go
··· 1 + package oproxy 2 + 3 + import ( 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 + 17 + type 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 + 29 + type PARResponse struct { 30 + RequestURI string `json:"request_uri"` 31 + ExpiresIn int `json:"expires_in"` 32 + } 33 + 34 + var ErrFirstNonce = echo.NewHTTPError(http.StatusBadRequest, "first time seeing this key, come back with a nonce") 35 + 36 + func (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 + 63 + func (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 + DID: 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 + }
+146
pkg/oproxy/oauth_2_authorize.go
··· 1 + package oproxy 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "time" 10 + 11 + oauth "github.com/haileyok/atproto-oauth-golang" 12 + "github.com/haileyok/atproto-oauth-golang/helpers" 13 + "github.com/labstack/echo/v4" 14 + "go.opentelemetry.io/otel" 15 + ) 16 + 17 + func (o *OProxy) 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, err := o.Authorize(ctx, requestURI, clientID) 30 + if err != nil { 31 + return err 32 + } 33 + return c.Redirect(http.StatusTemporaryRedirect, redirectURL) 34 + } 35 + 36 + // downstream --> upstream transition; attempt to send user to the upstream auth server 37 + func (o *OProxy) Authorize(ctx context.Context, requestURI, clientID string) (string, error) { 38 + downstreamMeta, err := o.GetDownstreamMetadata("") 39 + if err != nil { 40 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get downstream metadata: %s", err)) 41 + } 42 + if downstreamMeta.ClientID != clientID { 43 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("client ID mismatch: %s != %s", downstreamMeta.ClientID, clientID)) 44 + } 45 + 46 + jkt, _, err := parseURN(requestURI) 47 + if err != nil { 48 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse URN: %s", err)) 49 + } 50 + 51 + session, err := o.loadOAuthSession(jkt) 52 + if err != nil { 53 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to load OAuth session jkt=%s: %s", jkt, err)) 54 + } 55 + 56 + if session == nil { 57 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no session found for jkt=%s", jkt)) 58 + } 59 + 60 + if session.Status() != OAuthSessionStatePARCreated { 61 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in par-created state: %s", session.Status())) 62 + } 63 + 64 + if session.DownstreamPARRequestURI != requestURI { 65 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("request URI mismatch: %s != %s", session.DownstreamPARRequestURI, requestURI)) 66 + } 67 + 68 + now := time.Now() 69 + session.DownstreamPARUsedAt = &now 70 + err = o.updateOAuthSession(jkt, session) 71 + if err != nil { 72 + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) 73 + } 74 + 75 + upstreamMeta := o.GetUpstreamMetadata() 76 + oclient, err := oauth.NewClient(oauth.ClientArgs{ 77 + ClientJwk: o.upstreamJWK, 78 + ClientId: upstreamMeta.ClientID, 79 + RedirectUri: upstreamMeta.RedirectURIs[0], 80 + }) 81 + if err != nil { 82 + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create OAuth client: %s", err)) 83 + } 84 + 85 + did, err := resolveHandle(ctx, session.DID) 86 + if err != nil { 87 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve handle '%s': %s", session.DID, err)) 88 + } 89 + 90 + service, err := resolveService(ctx, did) 91 + if err != nil { 92 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve service for DID '%s': %s", did, err)) 93 + } 94 + 95 + authserver, err := oclient.ResolvePdsAuthServer(ctx, service) 96 + if err != nil { 97 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve PDS auth server for service '%s': %s", service, err)) 98 + } 99 + 100 + authmeta, err := oclient.FetchAuthServerMetadata(ctx, authserver) 101 + if err != nil { 102 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to fetch auth server metadata from '%s': %s", authserver, err)) 103 + } 104 + 105 + k, err := helpers.GenerateKey(nil) 106 + if err != nil { 107 + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate DPoP key: %s", err)) 108 + } 109 + 110 + state := makeState(jkt) 111 + 112 + opts := oauth.ParAuthRequestOpts{ 113 + State: state, 114 + } 115 + parResp, err := oclient.SendParAuthRequest(ctx, authserver, authmeta, did, upstreamMeta.Scope, k, opts) 116 + if err != nil { 117 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to send PAR auth request to '%s': %s", authserver, err)) 118 + } 119 + 120 + jwkJSON, err := json.Marshal(k) 121 + if err != nil { 122 + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to marshal DPoP key to JSON: %s", err)) 123 + } 124 + 125 + u, err := url.Parse(authmeta.AuthorizationEndpoint) 126 + if err != nil { 127 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse auth server metadata: %s", err)) 128 + } 129 + u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(upstreamMeta.ClientID), parResp.RequestUri) 130 + str := u.String() 131 + 132 + session.DID = did 133 + session.PDSUrl = service 134 + session.UpstreamState = parResp.State 135 + session.UpstreamAuthServerIssuer = authserver 136 + session.UpstreamPKCEVerifier = parResp.PkceVerifier 137 + session.UpstreamDPoPNonce = parResp.DpopAuthserverNonce 138 + session.UpstreamDPoPPrivateJWK = string(jwkJSON) 139 + 140 + err = o.updateOAuthSession(jkt, session) 141 + if err != nil { 142 + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) 143 + } 144 + 145 + return str, nil 146 + }
+129
pkg/oproxy/oauth_3_return.go
··· 1 + package oproxy 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + oauth "github.com/haileyok/atproto-oauth-golang" 13 + "github.com/labstack/echo/v4" 14 + "github.com/lestrrat-go/jwx/v2/jwk" 15 + "go.opentelemetry.io/otel" 16 + ) 17 + 18 + func (o *OProxy) HandleOAuthReturn(c echo.Context) error { 19 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthReturn") 20 + defer span.End() 21 + code := c.QueryParam("code") 22 + iss := c.QueryParam("iss") 23 + state := c.QueryParam("state") 24 + redirectURL, err := o.Return(ctx, code, iss, state) 25 + if err != nil { 26 + return err 27 + } 28 + return c.Redirect(http.StatusTemporaryRedirect, redirectURL) 29 + } 30 + 31 + func (o *OProxy) Return(ctx context.Context, code string, iss string, state string) (string, error) { 32 + upstreamMeta := o.GetUpstreamMetadata() 33 + oclient, err := oauth.NewClient(oauth.ClientArgs{ 34 + ClientJwk: o.upstreamJWK, 35 + ClientId: upstreamMeta.ClientID, 36 + RedirectUri: upstreamMeta.RedirectURIs[0], 37 + }) 38 + 39 + jkt, _, err := parseState(state) 40 + if err != nil { 41 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse state: %s", err)) 42 + } 43 + 44 + session, err := o.loadOAuthSession(jkt) 45 + if err != nil { 46 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get OAuth session: %s", err)) 47 + } 48 + if session == nil { 49 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no OAuth session found for state: %s", state)) 50 + } 51 + 52 + if session.Status() != OAuthSessionStateUpstream { 53 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in upstream state: %s", session.Status())) 54 + } 55 + 56 + if session.UpstreamState != state { 57 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("state mismatch: %s != %s", session.UpstreamState, state)) 58 + } 59 + 60 + if iss != session.UpstreamAuthServerIssuer { 61 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("issuer mismatch: %s != %s", iss, session.UpstreamAuthServerIssuer)) 62 + } 63 + 64 + key, err := jwk.ParseKey([]byte(session.UpstreamDPoPPrivateJWK)) 65 + if err != nil { 66 + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to parse DPoP private JWK: %s", err)) 67 + } 68 + 69 + itResp, err := oclient.InitialTokenRequest(ctx, code, iss, session.UpstreamPKCEVerifier, session.UpstreamDPoPNonce, key) 70 + if err != nil { 71 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to request initial token: %s", err)) 72 + } 73 + now := time.Now() 74 + 75 + if itResp.Sub != session.DID { 76 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("sub mismatch: %s != %s", itResp.Sub, session.DID)) 77 + } 78 + 79 + if itResp.Scope != upstreamMeta.Scope { 80 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("scope mismatch: %s != %s", itResp.Scope, upstreamMeta.Scope)) 81 + } 82 + 83 + downstreamCode, err := generateAuthorizationCode() 84 + if err != nil { 85 + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate downstream code: %s", err)) 86 + } 87 + 88 + expiry := now.Add(time.Second * time.Duration(itResp.ExpiresIn)).UTC() 89 + session.UpstreamAccessToken = itResp.AccessToken 90 + session.UpstreamAccessTokenExp = &expiry 91 + session.UpstreamRefreshToken = itResp.RefreshToken 92 + session.DownstreamAuthorizationCode = downstreamCode 93 + 94 + authArgs := &oauth.XrpcAuthedRequestArgs{ 95 + Did: session.DID, 96 + AccessToken: session.UpstreamAccessToken, 97 + PdsUrl: session.PDSUrl, 98 + Issuer: session.UpstreamAuthServerIssuer, 99 + DpopPdsNonce: session.UpstreamDPoPNonce, 100 + DpopPrivateJwk: key, 101 + } 102 + 103 + xrpcClient := &oauth.XrpcClient{ 104 + OnDpopPdsNonceChanged: func(did, newNonce string) {}, 105 + } 106 + 107 + // brief check to make sure we can actually do stuff 108 + var out atproto.ServerCheckAccountStatus_Output 109 + if err := xrpcClient.Do(ctx, authArgs, xrpc.Query, "application/json", "com.atproto.server.checkAccountStatus", nil, nil, &out); err != nil { 110 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to check account status: %s", err)) 111 + } 112 + 113 + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 114 + if err != nil { 115 + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) 116 + } 117 + 118 + u, err := url.Parse(session.DownstreamRedirectURI) 119 + if err != nil { 120 + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse downstream redirect URI: %s", err)) 121 + } 122 + q := u.Query() 123 + q.Set("iss", fmt.Sprintf("https://%s", o.host)) 124 + q.Set("state", session.DownstreamState) 125 + q.Set("code", session.DownstreamAuthorizationCode) 126 + u.RawQuery = q.Encode() 127 + 128 + return u.String(), nil 129 + }
+221
pkg/oproxy/oauth_4_token.go
··· 1 + package oproxy 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 *OProxy) 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.loadOAuthSession(jkt) 69 + if err != nil { 70 + return err 71 + } 72 + sess.DownstreamDPoPNonce = makeNonce() 73 + err = o.updateOAuthSession(sess.DownstreamDPoPJKT, sess) 74 + if err != nil { 75 + return err 76 + } 77 + c.Response().Header().Set("DPoP-Nonce", sess.DownstreamDPoPNonce) 78 + 79 + return c.JSON(http.StatusOK, res) 80 + } 81 + 82 + func (o *OProxy) Token(ctx context.Context, tokenRequest *TokenRequest, dpopHeader string) (*TokenResponse, error) { 83 + proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/token"}, dpop.ParseOptions{ 84 + Nonce: "", 85 + TimeWindow: &dpopTimeWindow, 86 + }) 87 + if err != nil { 88 + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid DPoP proof") 89 + } 90 + 91 + jkt := proof.PublicKey() 92 + session, err := o.loadOAuthSession(jkt) 93 + if err != nil { 94 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("could not get oauth session: %s", err)) 95 + } 96 + 97 + if tokenRequest.GrantType == "authorization_code" { 98 + return o.AccessToken(ctx, tokenRequest, session) 99 + } else if tokenRequest.GrantType == "refresh_token" { 100 + return o.RefreshToken(ctx, tokenRequest, session) 101 + } 102 + return nil, echo.NewHTTPError(http.StatusBadRequest, "unsupported grant type") 103 + } 104 + 105 + func (o *OProxy) AccessToken(ctx context.Context, tokenRequest *TokenRequest, session *OAuthSession) (*TokenResponse, error) { 106 + if session.Status() != OAuthSessionStateDownstream { 107 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in downstream state: %s", session.Status())) 108 + } 109 + 110 + // Hash the code verifier using SHA-256 111 + hasher := sha256.New() 112 + hasher.Write([]byte(tokenRequest.CodeVerifier)) 113 + codeChallenge := hasher.Sum(nil) 114 + 115 + encodedChallenge := base64.RawURLEncoding.WithPadding(base64.NoPadding).EncodeToString(codeChallenge) 116 + 117 + if session.DownstreamCodeChallenge != encodedChallenge { 118 + return nil, fmt.Errorf("invalid code challenge") 119 + } 120 + 121 + if session.DownstreamAuthorizationCode != tokenRequest.Code { 122 + return nil, fmt.Errorf("invalid authorization code") 123 + } 124 + 125 + accessToken, err := o.generateJWT(session) 126 + if err != nil { 127 + return nil, fmt.Errorf("could not generate access token: %w", err) 128 + } 129 + 130 + refreshToken, err := generateRefreshToken() 131 + if err != nil { 132 + return nil, fmt.Errorf("could not generate refresh token: %w", err) 133 + } 134 + 135 + session.DownstreamAccessToken = accessToken 136 + session.DownstreamRefreshToken = refreshToken 137 + 138 + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 139 + if err != nil { 140 + return nil, fmt.Errorf("could not update downstream session: %w", err) 141 + } 142 + 143 + return &TokenResponse{ 144 + AccessToken: accessToken, 145 + TokenType: "DPoP", 146 + RefreshToken: refreshToken, 147 + Scope: "atproto transition:generic", 148 + ExpiresIn: int(OAuthTokenExpiry.Seconds()), 149 + Sub: session.DID, 150 + }, nil 151 + } 152 + 153 + func (o *OProxy) RefreshToken(ctx context.Context, tokenRequest *TokenRequest, session *OAuthSession) (*TokenResponse, error) { 154 + 155 + if session.Status() != OAuthSessionStateReady { 156 + return nil, echo.NewHTTPError(http.StatusBadRequest, "session is not in ready state") 157 + } 158 + 159 + if session.DownstreamRefreshToken != tokenRequest.RefreshToken { 160 + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid refresh token") 161 + } 162 + 163 + newJWT, err := o.generateJWT(session) 164 + if err != nil { 165 + return nil, fmt.Errorf("could not generate new access token: %w", err) 166 + } 167 + 168 + session.DownstreamAccessToken = newJWT 169 + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 170 + if err != nil { 171 + return nil, fmt.Errorf("could not update downstream session: %w", err) 172 + } 173 + 174 + return &TokenResponse{ 175 + AccessToken: newJWT, 176 + TokenType: "DPoP", 177 + RefreshToken: session.DownstreamRefreshToken, 178 + Scope: "atproto transition:generic", 179 + ExpiresIn: int(OAuthTokenExpiry.Seconds()), 180 + Sub: session.DID, 181 + }, nil 182 + } 183 + 184 + func (o *OProxy) generateJWT(session *OAuthSession) (string, error) { 185 + uu, err := uuid.NewV7() 186 + if err != nil { 187 + return "", err 188 + } 189 + downstreamMeta, err := o.GetDownstreamMetadata("") 190 + if err != nil { 191 + return "", err 192 + } 193 + now := time.Now() 194 + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ 195 + "jti": uu.String(), 196 + "sub": session.DID, 197 + "exp": now.Add(OAuthTokenExpiry).Unix(), 198 + "iat": now.Unix(), 199 + "nbf": now.Unix(), 200 + "cnf": map[string]any{ 201 + "jkt": session.DownstreamDPoPJKT, 202 + }, 203 + "aud": fmt.Sprintf("did:web:%s", o.host), 204 + "scope": downstreamMeta.Scope, 205 + "client_id": downstreamMeta.ClientID, 206 + "iss": fmt.Sprintf("https://%s", o.host), 207 + }) 208 + 209 + var rawKey any 210 + if err := o.downstreamJWK.Raw(&rawKey); err != nil { 211 + return "", err 212 + } 213 + 214 + tokenString, err := token.SignedString(rawKey) 215 + 216 + if err != nil { 217 + return "", err 218 + } 219 + 220 + return tokenString, nil 221 + }
+56
pkg/oproxy/oauth_5_revoke.go
··· 1 + package oproxy 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "time" 10 + 11 + "github.com/AxisCommunications/go-dpop" 12 + "github.com/labstack/echo/v4" 13 + "go.opentelemetry.io/otel" 14 + ) 15 + 16 + func (o *OProxy) HandleOAuthRevoke(c echo.Context) error { 17 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthRevoke") 18 + defer span.End() 19 + var revokeRequest RevokeRequest 20 + if err := json.NewDecoder(c.Request().Body).Decode(&revokeRequest); err != nil { 21 + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request: %s", err)) 22 + } 23 + dpopHeader := c.Request().Header.Get("DPoP") 24 + if dpopHeader == "" { 25 + return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") 26 + } 27 + err := o.Revoke(ctx, dpopHeader, &revokeRequest) 28 + if err != nil { 29 + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("could not handle oauth revoke: %s", err)) 30 + } 31 + return c.JSON(http.StatusOK, map[string]interface{}{}) 32 + } 33 + 34 + func (o *OProxy) Revoke(ctx context.Context, dpopHeader string, revokeRequest *RevokeRequest) error { 35 + proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/revoke"}, dpop.ParseOptions{ 36 + Nonce: "", 37 + TimeWindow: &dpopTimeWindow, 38 + }) 39 + if err != nil { 40 + return echo.NewHTTPError(http.StatusBadRequest, "invalid DPoP proof") 41 + } 42 + 43 + session, err := o.loadOAuthSession(proof.PublicKey()) 44 + if err != nil { 45 + return fmt.Errorf("could not get downstream session: %w", err) 46 + } 47 + 48 + now := time.Now() 49 + session.RevokedAt = &now 50 + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 51 + if err != nil { 52 + return fmt.Errorf("could not update downstream session: %w", err) 53 + } 54 + 55 + return nil 56 + }
-454
pkg/oproxy/oauth_downstream.go
··· 1 - package oproxy 2 - 3 - import ( 4 - "context" 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 - ) 18 - 19 - type TokenRequest struct { 20 - GrantType string `json:"grant_type"` 21 - RedirectURI string `json:"redirect_uri"` 22 - Code string `json:"code"` 23 - CodeVerifier string `json:"code_verifier"` 24 - ClientID string `json:"client_id"` 25 - RefreshToken string `json:"refresh_token"` 26 - } 27 - 28 - type RevokeRequest struct { 29 - Token string `json:"token"` 30 - ClientID string `json:"client_id"` 31 - } 32 - 33 - type TokenResponse struct { 34 - AccessToken string `json:"access_token"` 35 - TokenType string `json:"token_type"` 36 - RefreshToken string `json:"refresh_token"` 37 - Scope string `json:"scope"` 38 - ExpiresIn int `json:"expires_in"` 39 - Sub string `json:"sub"` 40 - } 41 - 42 - var OAuthTokenExpiry = time.Hour * 24 43 - 44 - var dpopTimeWindow = time.Duration(30 * time.Second) 45 - 46 - func (o *OProxy) Token(ctx context.Context, tokenRequest *TokenRequest, dpopHeader string) (*TokenResponse, error) { 47 - proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/token"}, dpop.ParseOptions{ 48 - Nonce: "", 49 - TimeWindow: &dpopTimeWindow, 50 - }) 51 - if err != nil { 52 - return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid DPoP proof") 53 - } 54 - 55 - jkt := proof.PublicKey() 56 - session, err := o.loadOAuthSession(jkt) 57 - if err != nil { 58 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("could not get oauth session: %s", err)) 59 - } 60 - 61 - if tokenRequest.GrantType == "authorization_code" { 62 - return o.AccessToken(ctx, tokenRequest, session) 63 - } else if tokenRequest.GrantType == "refresh_token" { 64 - return o.RefreshToken(ctx, tokenRequest, session) 65 - } 66 - return nil, echo.NewHTTPError(http.StatusBadRequest, "unsupported grant type") 67 - } 68 - 69 - func (o *OProxy) AccessToken(ctx context.Context, tokenRequest *TokenRequest, session *OAuthSession) (*TokenResponse, error) { 70 - if session.Status() != OAuthSessionStateDownstream { 71 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in downstream state: %s", session.Status())) 72 - } 73 - 74 - // Hash the code verifier using SHA-256 75 - hasher := sha256.New() 76 - hasher.Write([]byte(tokenRequest.CodeVerifier)) 77 - codeChallenge := hasher.Sum(nil) 78 - 79 - encodedChallenge := base64.RawURLEncoding.WithPadding(base64.NoPadding).EncodeToString(codeChallenge) 80 - 81 - if session.DownstreamCodeChallenge != encodedChallenge { 82 - return nil, fmt.Errorf("invalid code challenge") 83 - } 84 - 85 - if session.DownstreamAuthorizationCode != tokenRequest.Code { 86 - return nil, fmt.Errorf("invalid authorization code") 87 - } 88 - 89 - accessToken, err := o.generateJWT(session) 90 - if err != nil { 91 - return nil, fmt.Errorf("could not generate access token: %w", err) 92 - } 93 - 94 - refreshToken, err := generateRefreshToken() 95 - if err != nil { 96 - return nil, fmt.Errorf("could not generate refresh token: %w", err) 97 - } 98 - 99 - session.DownstreamAccessToken = accessToken 100 - session.DownstreamRefreshToken = refreshToken 101 - 102 - err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 103 - if err != nil { 104 - return nil, fmt.Errorf("could not update downstream session: %w", err) 105 - } 106 - 107 - return &TokenResponse{ 108 - AccessToken: accessToken, 109 - TokenType: "DPoP", 110 - RefreshToken: refreshToken, 111 - Scope: "atproto transition:generic", 112 - ExpiresIn: int(OAuthTokenExpiry.Seconds()), 113 - Sub: session.DID, 114 - }, nil 115 - } 116 - 117 - func (o *OProxy) RefreshToken(ctx context.Context, tokenRequest *TokenRequest, session *OAuthSession) (*TokenResponse, error) { 118 - 119 - if session.Status() != OAuthSessionStateReady { 120 - return nil, echo.NewHTTPError(http.StatusBadRequest, "session is not in ready state") 121 - } 122 - 123 - if session.DownstreamRefreshToken != tokenRequest.RefreshToken { 124 - return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid refresh token") 125 - } 126 - 127 - newJWT, err := o.generateJWT(session) 128 - if err != nil { 129 - return nil, fmt.Errorf("could not generate new access token: %w", err) 130 - } 131 - 132 - session.DownstreamAccessToken = newJWT 133 - err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 134 - if err != nil { 135 - return nil, fmt.Errorf("could not update downstream session: %w", err) 136 - } 137 - 138 - return &TokenResponse{ 139 - AccessToken: newJWT, 140 - TokenType: "DPoP", 141 - RefreshToken: session.DownstreamRefreshToken, 142 - Scope: "atproto transition:generic", 143 - ExpiresIn: int(OAuthTokenExpiry.Seconds()), 144 - Sub: session.DID, 145 - }, nil 146 - } 147 - 148 - func (o *OProxy) Revoke(ctx context.Context, dpopHeader string, revokeRequest *RevokeRequest) error { 149 - proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/revoke"}, dpop.ParseOptions{ 150 - Nonce: "", 151 - TimeWindow: &dpopTimeWindow, 152 - }) 153 - if err != nil { 154 - return echo.NewHTTPError(http.StatusBadRequest, "invalid DPoP proof") 155 - } 156 - 157 - session, err := o.loadOAuthSession(proof.PublicKey()) 158 - if err != nil { 159 - return fmt.Errorf("could not get downstream session: %w", err) 160 - } 161 - 162 - now := time.Now() 163 - session.RevokedAt = &now 164 - err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 165 - if err != nil { 166 - return fmt.Errorf("could not update downstream session: %w", err) 167 - } 168 - 169 - return nil 170 - } 171 - 172 - func (o *OProxy) generateJWT(session *OAuthSession) (string, error) { 173 - uu, err := uuid.NewV7() 174 - if err != nil { 175 - return "", err 176 - } 177 - downstreamMeta, err := o.GetDownstreamMetadata("") 178 - if err != nil { 179 - return "", err 180 - } 181 - now := time.Now() 182 - token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ 183 - "jti": uu.String(), 184 - "sub": session.DID, 185 - "exp": now.Add(OAuthTokenExpiry).Unix(), 186 - "iat": now.Unix(), 187 - "nbf": now.Unix(), 188 - "cnf": map[string]any{ 189 - "jkt": session.DownstreamDPoPJKT, 190 - }, 191 - "aud": fmt.Sprintf("did:web:%s", o.host), 192 - "scope": downstreamMeta.Scope, 193 - "client_id": downstreamMeta.ClientID, 194 - "iss": fmt.Sprintf("https://%s", o.host), 195 - }) 196 - 197 - var rawKey any 198 - if err := o.downstreamJWK.Raw(&rawKey); err != nil { 199 - return "", err 200 - } 201 - 202 - tokenString, err := token.SignedString(rawKey) 203 - 204 - if err != nil { 205 - return "", err 206 - } 207 - 208 - return tokenString, nil 209 - } 210 - 211 - func (o *OProxy) DPoPNonceMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 212 - return func(c echo.Context) error { 213 - dpopHeader := c.Request().Header.Get("DPoP") 214 - if dpopHeader == "" { 215 - return echo.NewHTTPError(http.StatusBadRequest, "missing DPoP header") 216 - } 217 - 218 - jkt, _, err := getJKT(dpopHeader) 219 - if err != nil { 220 - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 221 - } 222 - 223 - session, err := o.loadOAuthSession(jkt) 224 - if err != nil { 225 - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 226 - } 227 - 228 - c.Set("session", session) 229 - return next(c) 230 - } 231 - } 232 - 233 - func (o *OProxy) ErrorHandlingMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 234 - return func(c echo.Context) error { 235 - err := next(c) 236 - if err == nil { 237 - return nil 238 - } 239 - httpError, ok := err.(*echo.HTTPError) 240 - if ok { 241 - o.slog.Error("oauth error", "code", httpError.Code, "message", httpError.Message, "internal", httpError.Internal) 242 - return err 243 - } 244 - o.slog.Error("unhandled error", "error", err) 245 - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 246 - } 247 - } 248 - 249 - func generateRefreshToken() (string, error) { 250 - uu, err := uuid.NewV7() 251 - if err != nil { 252 - return "", err 253 - } 254 - return fmt.Sprintf("refresh-%s", uu.String()), nil 255 - } 256 - 257 - func generateAuthorizationCode() (string, error) { 258 - uu, err := uuid.NewV7() 259 - if err != nil { 260 - return "", err 261 - } 262 - return fmt.Sprintf("code-%s", uu.String()), nil 263 - } 264 - 265 - func generateOAuthServerMetadata(host string) map[string]any { 266 - oauthServerMetadata := map[string]any{ 267 - "issuer": fmt.Sprintf("https://%s", host), 268 - "request_parameter_supported": true, 269 - "request_uri_parameter_supported": true, 270 - "require_request_uri_registration": true, 271 - "scopes_supported": []string{"atproto", "transition:generic", "transition:chat.bsky"}, 272 - "subject_types_supported": []string{"public"}, 273 - "response_types_supported": []string{"code"}, 274 - "response_modes_supported": []string{"query", "fragment", "form_post"}, 275 - "grant_types_supported": []string{"authorization_code", "refresh_token"}, 276 - "code_challenge_methods_supported": []string{"S256"}, 277 - "ui_locales_supported": []string{"en-US"}, 278 - "display_values_supported": []string{"page", "popup", "touch"}, 279 - "authorization_response_iss_parameter_supported": true, 280 - "request_object_encryption_alg_values_supported": []string{}, 281 - "request_object_encryption_enc_values_supported": []string{}, 282 - "jwks_uri": fmt.Sprintf("https://%s/oauth/jwks", host), 283 - "authorization_endpoint": fmt.Sprintf("https://%s/oauth/authorize", host), 284 - "token_endpoint": fmt.Sprintf("https://%s/oauth/token", host), 285 - "token_endpoint_auth_methods_supported": []string{"none", "private_key_jwt"}, 286 - "revocation_endpoint": fmt.Sprintf("https://%s/oauth/revoke", host), 287 - "introspection_endpoint": fmt.Sprintf("https://%s/oauth/introspect", host), 288 - "pushed_authorization_request_endpoint": fmt.Sprintf("https://%s/oauth/par", host), 289 - "require_pushed_authorization_requests": true, 290 - "client_id_metadata_document_supported": true, 291 - "request_object_signing_alg_values_supported": []string{ 292 - "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", 293 - "ES256", "ES256K", "ES384", "ES512", "none", 294 - }, 295 - "token_endpoint_auth_signing_alg_values_supported": []string{ 296 - "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", 297 - "ES256", "ES256K", "ES384", "ES512", 298 - }, 299 - "dpop_signing_alg_values_supported": []string{ 300 - "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", 301 - "ES256", "ES256K", "ES384", "ES512", 302 - }, 303 - } 304 - return oauthServerMetadata 305 - } 306 - 307 - func (o *OProxy) GetDownstreamMetadata(redirectURI string) (*OAuthClientMetadata, error) { 308 - meta := &OAuthClientMetadata{ 309 - ClientID: fmt.Sprintf("https://%s/oauth/downstream/client-metadata.json", o.host), 310 - ClientURI: fmt.Sprintf("https://%s", o.host), 311 - // RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)}, 312 - Scope: "atproto transition:generic", 313 - TokenEndpointAuthMethod: "none", 314 - ClientName: "Streamplace", 315 - ResponseTypes: []string{"code"}, 316 - GrantTypes: []string{"authorization_code", "refresh_token"}, 317 - DPoPBoundAccessTokens: boolPtr(true), 318 - RedirectURIs: []string{fmt.Sprintf("https://%s/login", o.host), fmt.Sprintf("https://%s/api/app-return", o.host)}, 319 - ApplicationType: "web", 320 - } 321 - if redirectURI != "" { 322 - found := false 323 - for _, uri := range meta.RedirectURIs { 324 - if uri == redirectURI { 325 - found = true 326 - break 327 - } 328 - } 329 - if !found { 330 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s not in allowed URIs", redirectURI)) 331 - } 332 - meta.RedirectURIs = []string{redirectURI} 333 - } 334 - return meta, nil 335 - } 336 - 337 - var ErrFirstNonce = echo.NewHTTPError(http.StatusBadRequest, "first time seeing this key, come back with a nonce") 338 - 339 - func (o *OProxy) NewPAR(ctx context.Context, c echo.Context, par *PAR, dpopHeader string) (*PARResponse, error) { 340 - jkt, nonce, err := getJKT(dpopHeader) 341 - if err != nil { 342 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get JKT from DPoP header header=%s: %s", dpopHeader, err)) 343 - } 344 - session, err := o.loadOAuthSession(jkt) 345 - if err != nil { 346 - return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to load OAuth session: %s", err)) 347 - } 348 - // special case - if this is the first request, we need to send it back for a new nonce 349 - if session == nil { 350 - _, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/par"}, dpop.ParseOptions{ 351 - Nonce: nonce, // normally this would be bad! but on the first request we're revalidating nonce anyway 352 - TimeWindow: &dpopTimeWindow, 353 - }) 354 - if err != nil { 355 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse DPoP header: %s", err)) 356 - } 357 - newNonce := makeNonce() 358 - err = o.createOAuthSession(jkt, &OAuthSession{ 359 - DownstreamDPoPJKT: jkt, 360 - DownstreamDPoPNonce: newNonce, 361 - }) 362 - if err != nil { 363 - return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create OAuth session: %s", err)) 364 - } 365 - // come back later, nerd 366 - c.Response().Header().Set("DPoP-Nonce", newNonce) 367 - return nil, ErrFirstNonce 368 - } 369 - if session.DownstreamDPoPNonce != nonce { 370 - return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid nonce") 371 - } 372 - proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/par"}, dpop.ParseOptions{ 373 - Nonce: session.DownstreamDPoPNonce, 374 - TimeWindow: &dpopTimeWindow, 375 - }) 376 - // Check the error type to determine response 377 - if err != nil { 378 - // if ok := errors.Is(err, dpop.ErrInvalidProof); ok { 379 - // apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", nil) 380 - // return 381 - // } 382 - // apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", err) 383 - // return 384 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid DPoP proof: %s", err)) 385 - } 386 - if proof.PublicKey() != jkt { 387 - panic("invalid code path: parsed DPoP proof twice and got different keys?!") 388 - } 389 - 390 - clientMetadata, err := o.GetDownstreamMetadata(par.RedirectURI) 391 - if err != nil { 392 - return nil, err 393 - } 394 - if par.ClientID != clientMetadata.ClientID { 395 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid client_id: expected %s, got %s", clientMetadata.ClientID, par.ClientID)) 396 - } 397 - 398 - if !slices.Contains(clientMetadata.RedirectURIs, par.RedirectURI) { 399 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s not in allowed URIs", par.RedirectURI)) 400 - } 401 - 402 - if par.CodeChallengeMethod != "S256" { 403 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid code challenge method: expected S256, got %s", par.CodeChallengeMethod)) 404 - } 405 - 406 - if par.ResponseMode != "query" { 407 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid response mode: expected query, got %s", par.ResponseMode)) 408 - } 409 - 410 - if par.ResponseType != "code" { 411 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid response type: expected code, got %s", par.ResponseType)) 412 - } 413 - 414 - if par.Scope != o.scope { 415 - return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid scope") 416 - } 417 - 418 - if par.LoginHint == "" { 419 - return nil, echo.NewHTTPError(http.StatusBadRequest, "login hint is required to find your PDS") 420 - } 421 - 422 - if par.State == "" { 423 - return nil, echo.NewHTTPError(http.StatusBadRequest, "state is required") 424 - } 425 - 426 - if par.Scope != o.scope { 427 - return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid scope (expected %s, got %s)", o.scope, par.Scope)) 428 - } 429 - 430 - urn := makeURN(jkt) 431 - 432 - newNonce := makeNonce() 433 - 434 - err = o.updateOAuthSession(jkt, &OAuthSession{ 435 - DownstreamDPoPJKT: jkt, 436 - DownstreamDPoPNonce: newNonce, 437 - DownstreamPARRequestURI: urn, 438 - DownstreamCodeChallenge: par.CodeChallenge, 439 - DownstreamState: par.State, 440 - DownstreamRedirectURI: par.RedirectURI, 441 - DID: par.LoginHint, 442 - }) 443 - if err != nil { 444 - return nil, fmt.Errorf("could not create oauth session: %w", err) 445 - } 446 - c.Response().Header().Set("DPoP-Nonce", newNonce) 447 - 448 - resp := &PARResponse{ 449 - RequestURI: urn, 450 - ExpiresIn: int(dpopTimeWindow.Seconds()), 451 - } 452 - 453 - return resp, nil 454 - }
+39
pkg/oproxy/oauth_middleware.go
··· 14 14 15 15 "github.com/AxisCommunications/go-dpop" 16 16 "github.com/golang-jwt/jwt/v5" 17 + "github.com/labstack/echo/v4" 17 18 ) 18 19 19 20 var OAuthSessionContextKey = oauthSessionContextKeyType{} ··· 193 194 194 195 return session, nil 195 196 } 197 + 198 + func (o *OProxy) DPoPNonceMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 199 + return func(c echo.Context) error { 200 + dpopHeader := c.Request().Header.Get("DPoP") 201 + if dpopHeader == "" { 202 + return echo.NewHTTPError(http.StatusBadRequest, "missing DPoP header") 203 + } 204 + 205 + jkt, _, err := getJKT(dpopHeader) 206 + if err != nil { 207 + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 208 + } 209 + 210 + session, err := o.loadOAuthSession(jkt) 211 + if err != nil { 212 + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 213 + } 214 + 215 + c.Set("session", session) 216 + return next(c) 217 + } 218 + } 219 + 220 + func (o *OProxy) ErrorHandlingMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 221 + return func(c echo.Context) error { 222 + err := next(c) 223 + if err == nil { 224 + return nil 225 + } 226 + httpError, ok := err.(*echo.HTTPError) 227 + if ok { 228 + o.slog.Error("oauth error", "code", httpError.Code, "message", httpError.Message, "internal", httpError.Internal) 229 + return err 230 + } 231 + o.slog.Error("unhandled error", "error", err) 232 + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 233 + } 234 + }
-258
pkg/oproxy/oauth_upstream.go
··· 1 - package oproxy 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "net/http" 8 - "net/url" 9 - "time" 10 - 11 - "github.com/bluesky-social/indigo/api/atproto" 12 - "github.com/bluesky-social/indigo/xrpc" 13 - oauth "github.com/haileyok/atproto-oauth-golang" 14 - "github.com/haileyok/atproto-oauth-golang/helpers" 15 - "github.com/labstack/echo/v4" 16 - "github.com/lestrrat-go/jwx/v2/jwk" 17 - ) 18 - 19 - // downstream --> upstream transition; attempt to send user to the upstream auth server 20 - func (o *OProxy) Authorize(ctx context.Context, requestURI, clientID string) (string, error) { 21 - downstreamMeta, err := o.GetDownstreamMetadata("") 22 - if err != nil { 23 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get downstream metadata: %s", err)) 24 - } 25 - if downstreamMeta.ClientID != clientID { 26 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("client ID mismatch: %s != %s", downstreamMeta.ClientID, clientID)) 27 - } 28 - 29 - jkt, _, err := parseURN(requestURI) 30 - if err != nil { 31 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse URN: %s", err)) 32 - } 33 - 34 - session, err := o.loadOAuthSession(jkt) 35 - if err != nil { 36 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to load OAuth session jkt=%s: %s", jkt, err)) 37 - } 38 - 39 - if session == nil { 40 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no session found for jkt=%s", jkt)) 41 - } 42 - 43 - if session.Status() != OAuthSessionStatePARCreated { 44 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in par-created state: %s", session.Status())) 45 - } 46 - 47 - if session.DownstreamPARRequestURI != requestURI { 48 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("request URI mismatch: %s != %s", session.DownstreamPARRequestURI, requestURI)) 49 - } 50 - 51 - now := time.Now() 52 - session.DownstreamPARUsedAt = &now 53 - err = o.updateOAuthSession(jkt, session) 54 - if err != nil { 55 - return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) 56 - } 57 - 58 - upstreamMeta := o.GetUpstreamMetadata() 59 - oclient, err := oauth.NewClient(oauth.ClientArgs{ 60 - ClientJwk: o.upstreamJWK, 61 - ClientId: upstreamMeta.ClientID, 62 - RedirectUri: upstreamMeta.RedirectURIs[0], 63 - }) 64 - if err != nil { 65 - return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create OAuth client: %s", err)) 66 - } 67 - 68 - did, err := resolveHandle(ctx, session.DID) 69 - if err != nil { 70 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve handle '%s': %s", session.DID, err)) 71 - } 72 - 73 - service, err := resolveService(ctx, did) 74 - if err != nil { 75 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve service for DID '%s': %s", did, err)) 76 - } 77 - 78 - authserver, err := oclient.ResolvePdsAuthServer(ctx, service) 79 - if err != nil { 80 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve PDS auth server for service '%s': %s", service, err)) 81 - } 82 - 83 - authmeta, err := oclient.FetchAuthServerMetadata(ctx, authserver) 84 - if err != nil { 85 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to fetch auth server metadata from '%s': %s", authserver, err)) 86 - } 87 - 88 - k, err := helpers.GenerateKey(nil) 89 - if err != nil { 90 - return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate DPoP key: %s", err)) 91 - } 92 - 93 - state := makeState(jkt) 94 - 95 - opts := oauth.ParAuthRequestOpts{ 96 - State: state, 97 - } 98 - parResp, err := oclient.SendParAuthRequest(ctx, authserver, authmeta, did, upstreamMeta.Scope, k, opts) 99 - if err != nil { 100 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to send PAR auth request to '%s': %s", authserver, err)) 101 - } 102 - 103 - jwkJSON, err := json.Marshal(k) 104 - if err != nil { 105 - return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to marshal DPoP key to JSON: %s", err)) 106 - } 107 - 108 - u, err := url.Parse(authmeta.AuthorizationEndpoint) 109 - if err != nil { 110 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse auth server metadata: %s", err)) 111 - } 112 - u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(upstreamMeta.ClientID), parResp.RequestUri) 113 - str := u.String() 114 - 115 - session.DID = did 116 - session.PDSUrl = service 117 - session.UpstreamState = parResp.State 118 - session.UpstreamAuthServerIssuer = authserver 119 - session.UpstreamPKCEVerifier = parResp.PkceVerifier 120 - session.UpstreamDPoPNonce = parResp.DpopAuthserverNonce 121 - session.UpstreamDPoPPrivateJWK = string(jwkJSON) 122 - 123 - err = o.updateOAuthSession(jkt, session) 124 - if err != nil { 125 - return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) 126 - } 127 - 128 - return str, nil 129 - } 130 - 131 - func (o *OProxy) Return(ctx context.Context, code string, iss string, state string) (string, error) { 132 - upstreamMeta := o.GetUpstreamMetadata() 133 - oclient, err := oauth.NewClient(oauth.ClientArgs{ 134 - ClientJwk: o.upstreamJWK, 135 - ClientId: upstreamMeta.ClientID, 136 - RedirectUri: upstreamMeta.RedirectURIs[0], 137 - }) 138 - 139 - jkt, _, err := parseState(state) 140 - if err != nil { 141 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse state: %s", err)) 142 - } 143 - 144 - session, err := o.loadOAuthSession(jkt) 145 - if err != nil { 146 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get OAuth session: %s", err)) 147 - } 148 - if session == nil { 149 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no OAuth session found for state: %s", state)) 150 - } 151 - 152 - if session.Status() != OAuthSessionStateUpstream { 153 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in upstream state: %s", session.Status())) 154 - } 155 - 156 - if session.UpstreamState != state { 157 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("state mismatch: %s != %s", session.UpstreamState, state)) 158 - } 159 - 160 - if iss != session.UpstreamAuthServerIssuer { 161 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("issuer mismatch: %s != %s", iss, session.UpstreamAuthServerIssuer)) 162 - } 163 - 164 - key, err := jwk.ParseKey([]byte(session.UpstreamDPoPPrivateJWK)) 165 - if err != nil { 166 - return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to parse DPoP private JWK: %s", err)) 167 - } 168 - 169 - itResp, err := oclient.InitialTokenRequest(ctx, code, iss, session.UpstreamPKCEVerifier, session.UpstreamDPoPNonce, key) 170 - if err != nil { 171 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to request initial token: %s", err)) 172 - } 173 - now := time.Now() 174 - 175 - if itResp.Sub != session.DID { 176 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("sub mismatch: %s != %s", itResp.Sub, session.DID)) 177 - } 178 - 179 - if itResp.Scope != upstreamMeta.Scope { 180 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("scope mismatch: %s != %s", itResp.Scope, upstreamMeta.Scope)) 181 - } 182 - 183 - downstreamCode, err := generateAuthorizationCode() 184 - if err != nil { 185 - return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate downstream code: %s", err)) 186 - } 187 - 188 - expiry := now.Add(time.Second * time.Duration(itResp.ExpiresIn)).UTC() 189 - session.UpstreamAccessToken = itResp.AccessToken 190 - session.UpstreamAccessTokenExp = &expiry 191 - session.UpstreamRefreshToken = itResp.RefreshToken 192 - session.DownstreamAuthorizationCode = downstreamCode 193 - 194 - authArgs := &oauth.XrpcAuthedRequestArgs{ 195 - Did: session.DID, 196 - AccessToken: session.UpstreamAccessToken, 197 - PdsUrl: session.PDSUrl, 198 - Issuer: session.UpstreamAuthServerIssuer, 199 - DpopPdsNonce: session.UpstreamDPoPNonce, 200 - DpopPrivateJwk: key, 201 - } 202 - 203 - xrpcClient := &oauth.XrpcClient{ 204 - OnDpopPdsNonceChanged: func(did, newNonce string) {}, 205 - } 206 - 207 - // brief check to make sure we can actually do stuff 208 - var out atproto.ServerCheckAccountStatus_Output 209 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Query, "application/json", "com.atproto.server.checkAccountStatus", nil, nil, &out); err != nil { 210 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to check account status: %s", err)) 211 - } 212 - 213 - err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) 214 - if err != nil { 215 - return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) 216 - } 217 - 218 - u, err := url.Parse(session.DownstreamRedirectURI) 219 - if err != nil { 220 - return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse downstream redirect URI: %s", err)) 221 - } 222 - q := u.Query() 223 - q.Set("iss", fmt.Sprintf("https://%s", o.host)) 224 - q.Set("state", session.DownstreamState) 225 - q.Set("code", session.DownstreamAuthorizationCode) 226 - u.RawQuery = q.Encode() 227 - 228 - return u.String(), nil 229 - } 230 - 231 - func (o *OProxy) GetUpstreamMetadata() *OAuthClientMetadata { 232 - // publicKey, err := o.upstreamJWK.PublicKey() 233 - // if err != nil { 234 - // panic(err) 235 - // } 236 - // jwks := jwk.NewSet() 237 - // err = jwks.AddKey(publicKey) 238 - // if err != nil { 239 - // panic(err) 240 - // } 241 - // ro := helpers.CreateJwksResponseObject(publicKey) 242 - meta := &OAuthClientMetadata{ 243 - ClientID: fmt.Sprintf("https://%s/oauth/upstream/client-metadata.json", o.host), 244 - JwksURI: fmt.Sprintf("https://%s/oauth/upstream/jwks.json", o.host), 245 - ClientURI: fmt.Sprintf("https://%s", o.host), 246 - // RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)}, 247 - Scope: "atproto transition:generic", 248 - TokenEndpointAuthMethod: "private_key_jwt", 249 - ClientName: "Streamplace", 250 - ResponseTypes: []string{"code"}, 251 - GrantTypes: []string{"authorization_code", "refresh_token"}, 252 - DPoPBoundAccessTokens: boolPtr(true), 253 - TokenEndpointAuthSigningAlg: "ES256", 254 - RedirectURIs: []string{fmt.Sprintf("https://%s/oauth/return", o.host)}, 255 - // Jwks: ro, 256 - } 257 - return meta 258 - }
+11
pkg/oproxy/oproxy.go
··· 2 2 3 3 import ( 4 4 "log/slog" 5 + "net/http" 5 6 "os" 6 7 7 8 "github.com/labstack/echo/v4" ··· 61 62 o.e.Use(o.ErrorHandlingMiddleware) 62 63 return o 63 64 } 65 + 66 + func (o *OProxy) Handler() http.Handler { 67 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 68 + w.Header().Set("Access-Control-Allow-Origin", "*") // todo: ehhhhhhhhhhhh 69 + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,DPoP") 70 + w.Header().Set("Access-Control-Allow-Methods", "*") 71 + w.Header().Set("Access-Control-Expose-Headers", "DPoP-Nonce") 72 + o.e.ServeHTTP(w, r) 73 + }) 74 + }
-18
pkg/oproxy/par.go
··· 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"` 18 - }
+1
pkg/oproxy/resolution.go
··· 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 ) 14 14 15 + // mostly borrowed from github.com/haileyok/atproto-oauth-golang, MIT license 15 16 func resolveHandle(ctx context.Context, handle string) (string, error) { 16 17 var did string 17 18
+23
pkg/oproxy/token_generation.go
··· 1 + package oproxy 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 + }