feat: oauth2_auth_url (pure), oauth2_exchange, oauth2_refresh

Fase 4 del issue 0010 — cliente OAuth2 sin golang.org/x/oauth2.
- Oauth2AuthURL es pura: solo construye la URL con net/url
- Oauth2Exchange/Refresh hacen POST application/x-www-form-urlencoded
- ExpiresAt calculado como now + expires_in del proveedor
- Refresh conserva el token original si el proveedor no devuelve uno nuevo
- Tests con httptest.NewServer como mock del proveedor
This commit is contained in:
2026-04-18 17:41:42 +02:00
parent 9153a20384
commit 1e5dfa5193
9 changed files with 501 additions and 0 deletions
+27
View File
@@ -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()
}
+45
View File
@@ -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.
+68
View File
@@ -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")
}
}
+95
View File
@@ -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
}
+46
View File
@@ -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.
+74
View File
@@ -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")
}
}
+34
View File
@@ -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
}
+43
View File
@@ -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.
+69
View File
@@ -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")
}
}