diff --git a/functions/infra/oauth2_auth_url.go b/functions/infra/oauth2_auth_url.go new file mode 100644 index 00000000..50b0ee8c --- /dev/null +++ b/functions/infra/oauth2_auth_url.go @@ -0,0 +1,27 @@ +package infra + +import ( + "net/url" + "strings" +) + +// Oauth2AuthURL construye la URL de autorizacion OAuth2 a partir de la config. +// Funcion pura — no hace I/O, solo concatenacion de strings. +// La URL resultante redirige al usuario al proveedor para que autorice el acceso. +func Oauth2AuthURL(config OAuthConfig, state string) string { + q := url.Values{} + q.Set("client_id", config.ClientID) + q.Set("redirect_uri", config.RedirectURL) + q.Set("response_type", "code") + if len(config.Scopes) > 0 { + q.Set("scope", strings.Join(config.Scopes, " ")) + } + if state != "" { + q.Set("state", state) + } + sep := "?" + if strings.Contains(config.AuthURL, "?") { + sep = "&" + } + return config.AuthURL + sep + q.Encode() +} diff --git a/functions/infra/oauth2_auth_url.md b/functions/infra/oauth2_auth_url.md new file mode 100644 index 00000000..aa2cdc1b --- /dev/null +++ b/functions/infra/oauth2_auth_url.md @@ -0,0 +1,45 @@ +--- +name: oauth2_auth_url +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func Oauth2AuthURL(config OAuthConfig, state string) string" +description: "Construye la URL de autorizacion OAuth2 a partir de la config. Funcion pura que concatena el AuthURL del proveedor con los query params (client_id, redirect_uri, response_type=code, scope, state)." +tags: [oauth, oauth2, auth, url, infra] +uses_functions: [] +uses_types: [OAuthConfig_go_infra] +returns: [] +returns_optional: false +error_type: "" +imports: [net/url, strings] +params: + - name: config + desc: "OAuthConfig del proveedor (Google, GitHub, etc.) con ClientID, AuthURL, RedirectURL y Scopes" + - name: state + desc: "valor aleatorio anti-CSRF que debe validarse en el callback. Si es vacio no se añade" +output: "URL completa a la que redirigir al usuario para iniciar el flujo OAuth2" +tested: true +tests: ["genera URL con todos los params basicos", "concatena scopes con espacio", "añade state si no es vacio", "detecta si AuthURL ya trae query y usa & en vez de ?"] +test_file_path: "functions/infra/oauth2_auth_url_test.go" +file_path: "functions/infra/oauth2_auth_url.go" +--- + +## Ejemplo + +```go +google := OAuthConfig{ + ClientID: os.Getenv("GOOGLE_CLIENT_ID"), + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + RedirectURL: "http://localhost:8080/callback", + Scopes: []string{"openid", "email", "profile"}, +} +state := "random-anti-csrf-token" // guardar en cookie/session +url := Oauth2AuthURL(google, state) +http.Redirect(w, r, url, http.StatusTemporaryRedirect) +``` + +## Notas + +Pura — solo hace string building con `net/url.Values.Encode()` (ordena params alfabeticamente y hace URL-encoding). No lee env, ni toca I/O, ni `time.Now()`. El state es critico para prevenir CSRF: debe ser aleatorio por sesion, guardarse server-side (cookie firmada, session, etc.) y validarse en el callback antes de hacer Oauth2Exchange. Un state vacio significa sin proteccion CSRF y no se incluye en la URL — solo apto para pruebas locales. diff --git a/functions/infra/oauth2_auth_url_test.go b/functions/infra/oauth2_auth_url_test.go new file mode 100644 index 00000000..dab97034 --- /dev/null +++ b/functions/infra/oauth2_auth_url_test.go @@ -0,0 +1,68 @@ +package infra + +import ( + "net/url" + "strings" + "testing" +) + +func TestOauth2AuthURL_BuildsBasicParams(t *testing.T) { + cfg := OAuthConfig{ + ClientID: "abc-client", + AuthURL: "https://example.com/authorize", + RedirectURL: "http://localhost/callback", + Scopes: []string{"openid", "email"}, + } + got := Oauth2AuthURL(cfg, "state-xyz") + u, err := url.Parse(got) + if err != nil { + t.Fatalf("parse: %v", err) + } + if u.Scheme != "https" || u.Host != "example.com" || u.Path != "/authorize" { + t.Fatalf("base URL incorrecta: %s", got) + } + q := u.Query() + if q.Get("client_id") != "abc-client" { + t.Errorf("client_id = %q", q.Get("client_id")) + } + if q.Get("redirect_uri") != "http://localhost/callback" { + t.Errorf("redirect_uri = %q", q.Get("redirect_uri")) + } + if q.Get("response_type") != "code" { + t.Errorf("response_type = %q", q.Get("response_type")) + } + if q.Get("scope") != "openid email" { + t.Errorf("scope = %q", q.Get("scope")) + } + if q.Get("state") != "state-xyz" { + t.Errorf("state = %q", q.Get("state")) + } +} + +func TestOauth2AuthURL_OmitsEmptyState(t *testing.T) { + cfg := OAuthConfig{ClientID: "c", AuthURL: "https://x.test/a", RedirectURL: "http://r"} + got := Oauth2AuthURL(cfg, "") + if strings.Contains(got, "state=") { + t.Errorf("state deberia estar ausente: %s", got) + } +} + +func TestOauth2AuthURL_HandlesExistingQueryString(t *testing.T) { + cfg := OAuthConfig{ + ClientID: "c", + AuthURL: "https://example.com/authorize?hd=domain.com", + RedirectURL: "http://r", + } + got := Oauth2AuthURL(cfg, "s") + u, err := url.Parse(got) + if err != nil { + t.Fatalf("parse: %v", err) + } + q := u.Query() + if q.Get("hd") != "domain.com" { + t.Errorf("param pre-existente se perdio") + } + if q.Get("client_id") != "c" { + t.Errorf("client_id no agregado") + } +} diff --git a/functions/infra/oauth2_exchange.go b/functions/infra/oauth2_exchange.go new file mode 100644 index 00000000..79f63fe0 --- /dev/null +++ b/functions/infra/oauth2_exchange.go @@ -0,0 +1,95 @@ +package infra + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// oauth2TokenResponse es la respuesta JSON estandar del endpoint token de OAuth2. +type oauth2TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// oauth2DoTokenRequest hace POST application/x-www-form-urlencoded al TokenURL +// con el body indicado, parsea la respuesta JSON y construye OAuthTokens. +func oauth2DoTokenRequest(tokenURL string, form url.Values) (OAuthTokens, error) { + var zero OAuthTokens + req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return zero, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return zero, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return zero, fmt.Errorf("read body: %w", err) + } + + var parsed oauth2TokenResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return zero, fmt.Errorf("parse json: %w (body=%s)", err, string(body)) + } + if parsed.Error != "" { + return zero, fmt.Errorf("oauth provider error: %s: %s", parsed.Error, parsed.ErrorDescription) + } + if resp.StatusCode >= 400 { + return zero, fmt.Errorf("http %d: %s", resp.StatusCode, string(body)) + } + if parsed.AccessToken == "" { + return zero, fmt.Errorf("respuesta sin access_token") + } + + var expiresAt int64 + if parsed.ExpiresIn > 0 { + expiresAt = time.Now().Unix() + parsed.ExpiresIn + } + + return OAuthTokens{ + AccessToken: parsed.AccessToken, + RefreshToken: parsed.RefreshToken, + TokenType: parsed.TokenType, + ExpiresAt: expiresAt, + }, nil +} + +// Oauth2Exchange intercambia un authorization code por tokens OAuth2. +// Hace POST al TokenURL con grant_type=authorization_code y las credenciales +// del cliente. Retorna OAuthTokens con AccessToken, RefreshToken y ExpiresAt. +func Oauth2Exchange(config OAuthConfig, code string) (OAuthTokens, error) { + var zero OAuthTokens + if code == "" { + return zero, fmt.Errorf("oauth2_exchange: code vacio") + } + if config.TokenURL == "" { + return zero, fmt.Errorf("oauth2_exchange: token_url vacio") + } + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("client_id", config.ClientID) + form.Set("client_secret", config.ClientSecret) + form.Set("redirect_uri", config.RedirectURL) + + tokens, err := oauth2DoTokenRequest(config.TokenURL, form) + if err != nil { + return zero, fmt.Errorf("oauth2_exchange: %w", err) + } + return tokens, nil +} diff --git a/functions/infra/oauth2_exchange.md b/functions/infra/oauth2_exchange.md new file mode 100644 index 00000000..0b2d95ce --- /dev/null +++ b/functions/infra/oauth2_exchange.md @@ -0,0 +1,46 @@ +--- +name: oauth2_exchange +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func Oauth2Exchange(config OAuthConfig, code string) (OAuthTokens, error)" +description: "Intercambia un authorization code por tokens OAuth2. POST al TokenURL del proveedor con grant_type=authorization_code y las credenciales del cliente. Retorna OAuthTokens con AccessToken, RefreshToken y ExpiresAt calculado." +tags: [oauth, oauth2, auth, token, exchange, http, infra] +uses_functions: [] +uses_types: [OAuthConfig_go_infra, OAuthTokens_go_infra] +returns: [OAuthTokens_go_infra] +returns_optional: false +error_type: error_go_core +imports: [encoding/json, fmt, io, net/http, net/url, strings, time] +params: + - name: config + desc: "OAuthConfig del proveedor con ClientID, ClientSecret, TokenURL y RedirectURL" + - name: code + desc: "authorization code recibido en el callback tras redirigir al usuario a la URL de Oauth2AuthURL" +output: "OAuthTokens con access/refresh tokens. ExpiresAt = now + expires_in del proveedor" +tested: true +tests: ["intercambia code por tokens contra mock server", "rechaza code vacio", "propaga error si proveedor devuelve error"] +test_file_path: "functions/infra/oauth2_exchange_test.go" +file_path: "functions/infra/oauth2_exchange.go" +--- + +## Ejemplo + +```go +code := r.URL.Query().Get("code") +state := r.URL.Query().Get("state") +// Validar state contra el guardado en cookie/session... + +tokens, err := Oauth2Exchange(googleConfig, code) +if err != nil { + HTTPErrorResponse(w, HTTPError{Status: 500, Code: "oauth_error", Message: err.Error()}) + return +} +// Usar tokens.AccessToken para llamar a APIs del proveedor +``` + +## Notas + +Impura — hace POST HTTP al TokenURL con timeout de 30s, y usa `time.Now()` para calcular ExpiresAt. El body es application/x-www-form-urlencoded (estandar OAuth2). Si el proveedor retorna JSON con campo `error` se wrappea en un error descriptivo. El ClientSecret se envia en el body (no en header Authorization Basic) para compatibilidad amplia — la mayoria de proveedores aceptan ambos. NO valida el state anti-CSRF: eso debe hacerlo el handler del callback antes de llamar a Oauth2Exchange. diff --git a/functions/infra/oauth2_exchange_test.go b/functions/infra/oauth2_exchange_test.go new file mode 100644 index 00000000..07076d8e --- /dev/null +++ b/functions/infra/oauth2_exchange_test.go @@ -0,0 +1,74 @@ +package infra + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOauth2Exchange_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("metodo = %s", r.Method) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if got := r.PostForm.Get("grant_type"); got != "authorization_code" { + t.Errorf("grant_type = %q", got) + } + if got := r.PostForm.Get("code"); got != "abc-code" { + t.Errorf("code = %q", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "at-123", + "refresh_token": "rt-456", + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + defer srv.Close() + + cfg := OAuthConfig{ + ClientID: "c", + ClientSecret: "s", + TokenURL: srv.URL, + RedirectURL: "http://r", + } + tokens, err := Oauth2Exchange(cfg, "abc-code") + if err != nil { + t.Fatalf("Oauth2Exchange: %v", err) + } + if tokens.AccessToken != "at-123" || tokens.RefreshToken != "rt-456" || tokens.TokenType != "Bearer" { + t.Errorf("tokens = %+v", tokens) + } + if tokens.ExpiresAt == 0 { + t.Error("ExpiresAt no deberia ser 0") + } +} + +func TestOauth2Exchange_EmptyCode(t *testing.T) { + cfg := OAuthConfig{TokenURL: "http://x", ClientID: "c"} + if _, err := Oauth2Exchange(cfg, ""); err == nil { + t.Fatal("esperaba error con code vacio") + } +} + +func TestOauth2Exchange_ProviderError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid_grant", + "error_description": "code expired", + }) + })) + defer srv.Close() + + cfg := OAuthConfig{TokenURL: srv.URL, ClientID: "c"} + if _, err := Oauth2Exchange(cfg, "code"); err == nil { + t.Fatal("esperaba error del proveedor") + } +} diff --git a/functions/infra/oauth2_refresh.go b/functions/infra/oauth2_refresh.go new file mode 100644 index 00000000..42817739 --- /dev/null +++ b/functions/infra/oauth2_refresh.go @@ -0,0 +1,34 @@ +package infra + +import ( + "fmt" + "net/url" +) + +// Oauth2Refresh renueva un access token OAuth2 usando el refresh token. +// POST al TokenURL con grant_type=refresh_token. Retorna OAuthTokens con +// el nuevo AccessToken (y posiblemente un nuevo RefreshToken segun el proveedor). +func Oauth2Refresh(config OAuthConfig, refreshToken string) (OAuthTokens, error) { + var zero OAuthTokens + if refreshToken == "" { + return zero, fmt.Errorf("oauth2_refresh: refresh_token vacio") + } + if config.TokenURL == "" { + return zero, fmt.Errorf("oauth2_refresh: token_url vacio") + } + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + form.Set("client_id", config.ClientID) + form.Set("client_secret", config.ClientSecret) + + tokens, err := oauth2DoTokenRequest(config.TokenURL, form) + if err != nil { + return zero, fmt.Errorf("oauth2_refresh: %w", err) + } + // Algunos proveedores no devuelven refresh_token al renovar — conservar el original + if tokens.RefreshToken == "" { + tokens.RefreshToken = refreshToken + } + return tokens, nil +} diff --git a/functions/infra/oauth2_refresh.md b/functions/infra/oauth2_refresh.md new file mode 100644 index 00000000..1ed720be --- /dev/null +++ b/functions/infra/oauth2_refresh.md @@ -0,0 +1,43 @@ +--- +name: oauth2_refresh +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func Oauth2Refresh(config OAuthConfig, refreshToken string) (OAuthTokens, error)" +description: "Renueva un access token OAuth2 usando el refresh token. POST al TokenURL con grant_type=refresh_token. Conserva el refresh token original si el proveedor no devuelve uno nuevo." +tags: [oauth, oauth2, auth, token, refresh, http, infra] +uses_functions: [] +uses_types: [OAuthConfig_go_infra, OAuthTokens_go_infra] +returns: [OAuthTokens_go_infra] +returns_optional: false +error_type: error_go_core +imports: [fmt, net/url] +params: + - name: config + desc: "OAuthConfig del proveedor con ClientID, ClientSecret y TokenURL" + - name: refreshToken + desc: "refresh token obtenido previamente de Oauth2Exchange" +output: "OAuthTokens con nuevo AccessToken. Si el proveedor no devuelve RefreshToken se conserva el original" +tested: true +tests: ["renueva tokens contra mock server", "conserva refresh token si el proveedor no devuelve uno nuevo", "rechaza refresh vacio"] +test_file_path: "functions/infra/oauth2_refresh_test.go" +file_path: "functions/infra/oauth2_refresh.go" +--- + +## Ejemplo + +```go +tokens, err := Oauth2Refresh(googleConfig, storedRefreshToken) +if err != nil { + // El refresh token tambien puede haber caducado — forzar relogin + HTTPErrorResponse(w, HTTPError{Status: 401, Code: "refresh_failed", Message: err.Error()}) + return +} +saveTokens(tokens) // actualizar tokens en BD/cookie +``` + +## Notas + +Impura — reutiliza oauth2DoTokenRequest para el POST. Algunos proveedores (Google) no devuelven un nuevo RefreshToken al renovar — en ese caso se conserva el original. Otros (Microsoft) pueden rotar el refresh token en cada renovacion: el campo tokens.RefreshToken siempre trae el que hay que guardar para la proxima renovacion. Si el refresh token expiro (el usuario revoco acceso o paso demasiado tiempo) el proveedor retorna 400 con `error: invalid_grant` y se propaga como error. diff --git a/functions/infra/oauth2_refresh_test.go b/functions/infra/oauth2_refresh_test.go new file mode 100644 index 00000000..9677a64b --- /dev/null +++ b/functions/infra/oauth2_refresh_test.go @@ -0,0 +1,69 @@ +package infra + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOauth2Refresh_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if got := r.PostForm.Get("grant_type"); got != "refresh_token" { + t.Errorf("grant_type = %q", got) + } + if got := r.PostForm.Get("refresh_token"); got != "rt-old" { + t.Errorf("refresh_token = %q", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "at-new", + "refresh_token": "rt-new", + "token_type": "Bearer", + "expires_in": 1800, + }) + })) + defer srv.Close() + + cfg := OAuthConfig{TokenURL: srv.URL, ClientID: "c", ClientSecret: "s"} + tokens, err := Oauth2Refresh(cfg, "rt-old") + if err != nil { + t.Fatalf("Oauth2Refresh: %v", err) + } + if tokens.AccessToken != "at-new" { + t.Errorf("AccessToken = %q", tokens.AccessToken) + } + if tokens.RefreshToken != "rt-new" { + t.Errorf("RefreshToken = %q", tokens.RefreshToken) + } +} + +func TestOauth2Refresh_PreservesRefreshTokenIfProviderOmits(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "at-new", + "token_type": "Bearer", + "expires_in": 1800, + // no refresh_token + }) + })) + defer srv.Close() + + cfg := OAuthConfig{TokenURL: srv.URL} + tokens, err := Oauth2Refresh(cfg, "rt-keep") + if err != nil { + t.Fatalf("Oauth2Refresh: %v", err) + } + if tokens.RefreshToken != "rt-keep" { + t.Errorf("esperaba conservar rt-keep, got %q", tokens.RefreshToken) + } +} + +func TestOauth2Refresh_EmptyToken(t *testing.T) { + cfg := OAuthConfig{TokenURL: "http://x"} + if _, err := Oauth2Refresh(cfg, ""); err == nil { + t.Fatal("esperaba error con refresh vacio") + } +}