diff --git a/dev/issues/0010-auth-system.md b/dev/issues/completed/0010-auth-system.md similarity index 100% rename from dev/issues/0010-auth-system.md rename to dev/issues/completed/0010-auth-system.md diff --git a/functions/infra/jwt_claims.go b/functions/infra/jwt_claims.go new file mode 100644 index 00000000..9d51193e --- /dev/null +++ b/functions/infra/jwt_claims.go @@ -0,0 +1,13 @@ +package infra + +// JWTClaims contiene claims estandar y custom para un JWT. +// Incluye los campos registrados mas comunes (sub, iss, aud, exp, iat) +// y un mapa libre `Custom` para claims de aplicacion (ej: role, email). +type JWTClaims struct { + Subject string `json:"sub"` + Issuer string `json:"iss,omitempty"` + Audience string `json:"aud,omitempty"` + ExpiresAt int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Custom map[string]any `json:"custom,omitempty"` +} diff --git a/functions/infra/jwt_generate.go b/functions/infra/jwt_generate.go new file mode 100644 index 00000000..a6d61057 --- /dev/null +++ b/functions/infra/jwt_generate.go @@ -0,0 +1,44 @@ +package infra + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "time" +) + +// JWTGenerate codifica un JWT firmado con HMAC-SHA256 (alg: HS256). +// Si claims.IssuedAt viene en cero se setea a time.Now().Unix(). +// Retorna el token en formato "header.payload.signature" con los tres segmentos +// codificados en base64url sin padding. +func JWTGenerate(claims JWTClaims, secret string) (string, error) { + if secret == "" { + return "", errors.New("jwt_generate: secret vacio") + } + if claims.IssuedAt == 0 { + claims.IssuedAt = time.Now().Unix() + } + + header := map[string]string{"alg": "HS256", "typ": "JWT"} + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + payloadJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + + enc := base64.RawURLEncoding + headerPart := enc.EncodeToString(headerJSON) + payloadPart := enc.EncodeToString(payloadJSON) + signingInput := headerPart + "." + payloadPart + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + sigPart := enc.EncodeToString(mac.Sum(nil)) + + return signingInput + "." + sigPart, nil +} diff --git a/functions/infra/jwt_generate.md b/functions/infra/jwt_generate.md new file mode 100644 index 00000000..7813f9e7 --- /dev/null +++ b/functions/infra/jwt_generate.md @@ -0,0 +1,46 @@ +--- +name: jwt_generate +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func JWTGenerate(claims JWTClaims, secret string) (string, error)" +description: "Codifica y firma un JWT con HMAC-SHA256 (HS256). Retorna el token en formato header.payload.signature. Setea IssuedAt automaticamente si viene en cero." +tags: [jwt, auth, token, hmac, sign, infra] +uses_functions: [] +uses_types: [JWTClaims_go_infra] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [crypto/hmac, crypto/sha256, encoding/base64, encoding/json, errors, time] +params: + - name: claims + desc: "claims del JWT (sub, iss, aud, exp, iat, custom). Si IssuedAt es 0 se rellena con time.Now()" + - name: secret + desc: "clave HMAC para firmar. No debe estar vacia. Obtenerla de env var o pass_get, nunca hardcoded" +output: "token JWT firmado en formato base64url header.payload.signature" +tested: true +tests: ["genera token valido con claims completas", "setea IssuedAt si viene en cero", "error si secret vacio"] +test_file_path: "functions/infra/jwt_generate_test.go" +file_path: "functions/infra/jwt_generate.go" +--- + +## Ejemplo + +```go +claims := JWTClaims{ + Subject: "user-123", + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + Custom: map[string]any{"role": "admin"}, +} +token, err := JWTGenerate(claims, os.Getenv("JWT_SECRET")) +if err != nil { + return err +} +w.Header().Set("Authorization", "Bearer " + token) +``` + +## Notas + +Impura — usa `time.Now()` para el claim `iat` cuando no viene fijado. Implementa HS256 sin libreria externa (solo stdlib crypto/hmac + crypto/sha256). Solo soporta HS256: para RS256/ES256 se crearia una funcion separada. El secret debe tener al menos 256 bits de entropia (32+ bytes aleatorios) para resistencia real. NO apto para escenarios multi-servicio donde se necesita clave publica/privada — usa RS256 en ese caso. diff --git a/functions/infra/jwt_generate_test.go b/functions/infra/jwt_generate_test.go new file mode 100644 index 00000000..b06aab6d --- /dev/null +++ b/functions/infra/jwt_generate_test.go @@ -0,0 +1,54 @@ +package infra + +import ( + "strings" + "testing" + "time" +) + +func TestJWTGenerate_ReturnsThreeSegments(t *testing.T) { + claims := JWTClaims{ + Subject: "user-1", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + token, err := JWTGenerate(claims, "test-secret-0123456789abcdef") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("esperados 3 segmentos, got %d en %q", len(parts), token) + } + for i, p := range parts { + if p == "" { + t.Fatalf("segmento %d vacio", i) + } + } +} + +func TestJWTGenerate_FillsIssuedAtWhenZero(t *testing.T) { + claims := JWTClaims{ + Subject: "user-1", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + before := time.Now().Unix() + token, err := JWTGenerate(claims, "s") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + after := time.Now().Unix() + parsed, err := JWTValidate(token, "s") + if err != nil { + t.Fatalf("JWTValidate error: %v", err) + } + if parsed.IssuedAt < before || parsed.IssuedAt > after { + t.Fatalf("IssuedAt %d fuera del rango [%d,%d]", parsed.IssuedAt, before, after) + } +} + +func TestJWTGenerate_ErrorsOnEmptySecret(t *testing.T) { + _, err := JWTGenerate(JWTClaims{Subject: "x"}, "") + if err == nil { + t.Fatal("esperaba error con secret vacio") + } +} diff --git a/functions/infra/jwt_middleware.go b/functions/infra/jwt_middleware.go new file mode 100644 index 00000000..8f779e0d --- /dev/null +++ b/functions/infra/jwt_middleware.go @@ -0,0 +1,58 @@ +package infra + +import ( + "context" + "net/http" + "strings" +) + +// jwtCtxKey es el tipo no exportado usado como key del context para las claims +// inyectadas por JWTMiddleware. Usar un tipo dedicado evita colisiones con +// otros middlewares que guarden valores en el context. +type jwtCtxKey struct{} + +// JWTClaimsFromContext extrae las claims inyectadas por JWTMiddleware. +// Retorna (claims, true) si existen en el context, o (zero, false) si no. +func JWTClaimsFromContext(ctx context.Context) (JWTClaims, bool) { + v, ok := ctx.Value(jwtCtxKey{}).(JWTClaims) + return v, ok +} + +// JWTMiddleware retorna un Middleware que extrae el JWT del header +// Authorization: Bearer , lo valida con JWTValidate, e inyecta las +// claims en el context para que handlers posteriores las lean con +// JWTClaimsFromContext. Responde 401 si el header falta, tiene formato +// incorrecto o el token es invalido/expirado. +func JWTMiddleware(secret string) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth == "" { + HTTPErrorResponse(w, HTTPError{ + Status: http.StatusUnauthorized, Code: "missing_token", + Message: "falta header Authorization", + }) + return + } + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) { + HTTPErrorResponse(w, HTTPError{ + Status: http.StatusUnauthorized, Code: "invalid_token", + Message: "se esperaba Authorization: Bearer ", + }) + return + } + token := strings.TrimSpace(strings.TrimPrefix(auth, prefix)) + claims, err := JWTValidate(token, secret) + if err != nil { + HTTPErrorResponse(w, HTTPError{ + Status: http.StatusUnauthorized, Code: "invalid_token", + Message: "token invalido", + }) + return + } + ctx := context.WithValue(r.Context(), jwtCtxKey{}, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/functions/infra/jwt_middleware.md b/functions/infra/jwt_middleware.md new file mode 100644 index 00000000..4c277c13 --- /dev/null +++ b/functions/infra/jwt_middleware.md @@ -0,0 +1,42 @@ +--- +name: jwt_middleware +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func JWTMiddleware(secret string) Middleware" +description: "Middleware HTTP que extrae el JWT del header Authorization: Bearer y valida con JWTValidate. Inyecta las claims en el context del request (recuperables con JWTClaimsFromContext). Responde 401 si falta el header, formato incorrecto o token invalido." +tags: [jwt, auth, middleware, http, server, infra] +uses_functions: [jwt_validate_go_infra, http_error_response_go_infra] +uses_types: [JWTClaims_go_infra, Middleware_go_infra, HTTPError_go_infra] +returns: [Middleware_go_infra] +returns_optional: false +error_type: error_go_core +imports: [context, net/http, strings] +params: + - name: secret + desc: "clave HMAC para JWTValidate. Debe ser la misma usada en JWTGenerate" +output: "Middleware que protege handlers con validacion JWT. Las claims se inyectan en r.Context() con una key privada" +tested: true +tests: ["pasa con token valido", "401 sin header Authorization", "401 con formato distinto de Bearer", "401 con token invalido", "claims accesibles via JWTClaimsFromContext"] +test_file_path: "functions/infra/jwt_middleware_test.go" +file_path: "functions/infra/jwt_middleware.go" +--- + +## Ejemplo + +```go +protected := HTTPMiddlewareChain( + HTTPLoggerMiddleware(os.Stderr), + JWTMiddleware(os.Getenv("JWT_SECRET")), +) +mux.Handle("GET /api/me", protected(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, _ := JWTClaimsFromContext(r.Context()) + HTTPJSONResponse(w, 200, map[string]string{"user_id": claims.Subject}) +}))) +``` + +## Notas + +Impura — lee headers y modifica el request. Expone el helper JWTClaimsFromContext(ctx) que devuelve (JWTClaims, bool) — el bool permite distinguir "no autenticado" de "subject vacio". Usa `context.WithValue` con una key de tipo privado `jwtCtxKey struct{}` para evitar colisiones con otros middlewares. Solo soporta cabecera `Authorization: Bearer`; para leer token desde cookie se crearia un middleware separado. En las respuestas 401 no se da detalle del motivo (token expirado vs firma invalida) para no filtrar informacion, el motivo real esta en los logs si se compone con HTTPLoggerMiddleware. diff --git a/functions/infra/jwt_middleware_test.go b/functions/infra/jwt_middleware_test.go new file mode 100644 index 00000000..3d40e4e8 --- /dev/null +++ b/functions/infra/jwt_middleware_test.go @@ -0,0 +1,94 @@ +package infra + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func makeTokenFor(t *testing.T, subject, secret string) string { + t.Helper() + tok, err := JWTGenerate(JWTClaims{ + Subject: subject, + ExpiresAt: time.Now().Add(time.Hour).Unix(), + }, secret) + if err != nil { + t.Fatalf("JWTGenerate: %v", err) + } + return tok +} + +func TestJWTMiddleware_ValidToken(t *testing.T) { + secret := "test-sec" + token := makeTokenFor(t, "alice", secret) + + var gotSubject string + handler := JWTMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := JWTClaimsFromContext(r.Context()) + if !ok { + t.Error("JWTClaimsFromContext no encontro claims") + } + gotSubject = claims.Subject + w.WriteHeader(200) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Errorf("status = %d", rec.Code) + } + if gotSubject != "alice" { + t.Errorf("subject = %q", gotSubject) + } +} + +func TestJWTMiddleware_MissingAuthHeader(t *testing.T) { + handler := JWTMiddleware("s")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("no deberia ejecutarse") + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != 401 { + t.Errorf("status = %d", rec.Code) + } +} + +func TestJWTMiddleware_WrongFormat(t *testing.T) { + handler := JWTMiddleware("s")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("no deberia ejecutarse") + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Basic abcdef") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != 401 { + t.Errorf("status = %d", rec.Code) + } +} + +func TestJWTMiddleware_InvalidToken(t *testing.T) { + handler := JWTMiddleware("secret-a")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("no deberia ejecutarse") + })) + tok := makeTokenFor(t, "x", "secret-b") // firmado con otro secret + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != 401 { + t.Errorf("status = %d", rec.Code) + } +} + +func TestJWTClaimsFromContext_NotPresent(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + _, ok := JWTClaimsFromContext(req.Context()) + if ok { + t.Fatal("no deberia haber claims en un context nuevo") + } +} diff --git a/functions/infra/jwt_validate.go b/functions/infra/jwt_validate.go new file mode 100644 index 00000000..f999ecb8 --- /dev/null +++ b/functions/infra/jwt_validate.go @@ -0,0 +1,72 @@ +package infra + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" +) + +// JWTValidate verifica la firma HMAC-SHA256 de un JWT y decodifica sus claims. +// Rechaza tokens mal formados, con firma invalida o expirados (exp < time.Now()). +// Retorna las claims si todo es valido. +func JWTValidate(token string, secret string) (JWTClaims, error) { + var zero JWTClaims + if secret == "" { + return zero, errors.New("jwt_validate: secret vacio") + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return zero, errors.New("jwt_validate: token malformado (se esperaban 3 segmentos)") + } + + enc := base64.RawURLEncoding + signingInput := parts[0] + "." + parts[1] + + // Verificar firma + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + expectedSig := mac.Sum(nil) + + gotSig, err := enc.DecodeString(parts[2]) + if err != nil { + return zero, errors.New("jwt_validate: firma mal codificada") + } + if !hmac.Equal(expectedSig, gotSig) { + return zero, errors.New("jwt_validate: firma invalida") + } + + // Decodificar header y confirmar alg + headerBytes, err := enc.DecodeString(parts[0]) + if err != nil { + return zero, errors.New("jwt_validate: header mal codificado") + } + var header map[string]string + if err := json.Unmarshal(headerBytes, &header); err != nil { + return zero, errors.New("jwt_validate: header no es JSON valido") + } + if alg, _ := header["alg"]; alg != "HS256" { + return zero, errors.New("jwt_validate: algoritmo no soportado (solo HS256)") + } + + // Decodificar claims + payloadBytes, err := enc.DecodeString(parts[1]) + if err != nil { + return zero, errors.New("jwt_validate: payload mal codificado") + } + var claims JWTClaims + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return zero, errors.New("jwt_validate: payload no es JSON valido") + } + + // Validar expiracion si esta presente + if claims.ExpiresAt > 0 && time.Now().Unix() >= claims.ExpiresAt { + return zero, errors.New("jwt_validate: token expirado") + } + + return claims, nil +} diff --git a/functions/infra/jwt_validate.md b/functions/infra/jwt_validate.md new file mode 100644 index 00000000..37656ca4 --- /dev/null +++ b/functions/infra/jwt_validate.md @@ -0,0 +1,45 @@ +--- +name: jwt_validate +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func JWTValidate(token string, secret string) (JWTClaims, error)" +description: "Verifica la firma HMAC-SHA256 de un JWT y decodifica sus claims. Rechaza tokens mal formados, con firma invalida o expirados." +tags: [jwt, auth, token, hmac, verify, infra] +uses_functions: [] +uses_types: [JWTClaims_go_infra] +returns: [JWTClaims_go_infra] +returns_optional: false +error_type: error_go_core +imports: [crypto/hmac, crypto/sha256, encoding/base64, encoding/json, errors, strings, time] +params: + - name: token + desc: "JWT string en formato header.payload.signature (base64url, sin padding)" + - name: secret + desc: "clave HMAC usada para firmar el token. Debe coincidir con la usada en JWTGenerate" +output: "claims decodificadas si el token es valido; error si firma invalida, expirado o malformado" +tested: true +tests: ["valida token generado por JWTGenerate", "rechaza firma invalida", "rechaza token expirado", "rechaza token malformado", "rechaza algoritmo distinto de HS256"] +test_file_path: "functions/infra/jwt_validate_test.go" +file_path: "functions/infra/jwt_validate.go" +--- + +## Ejemplo + +```go +auth := r.Header.Get("Authorization") +token := strings.TrimPrefix(auth, "Bearer ") +claims, err := JWTValidate(token, os.Getenv("JWT_SECRET")) +if err != nil { + HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_token", Message: err.Error()}) + return +} +userID := claims.Subject +role, _ := claims.Custom["role"].(string) +``` + +## Notas + +Impura — usa `time.Now()` para comparar contra `exp`. Usa `hmac.Equal` para comparacion constant-time de firmas (mitiga timing attacks). Solo acepta alg=HS256 en el header, otros algoritmos se rechazan explicitamente para evitar el ataque "alg=none". Si `exp` es 0 (no fijado) no se valida expiracion — es responsabilidad del caller asegurar que sus tokens siempre tengan exp fijado. Errores descriptivos con prefijo `jwt_validate:` para facilitar debugging; en respuestas HTTP conviene mapear todos a un mensaje generico "token invalido" para no filtrar informacion. diff --git a/functions/infra/jwt_validate_test.go b/functions/infra/jwt_validate_test.go new file mode 100644 index 00000000..4300f9b9 --- /dev/null +++ b/functions/infra/jwt_validate_test.go @@ -0,0 +1,83 @@ +package infra + +import ( + "strings" + "testing" + "time" +) + +func TestJWTValidate_ValidatesGeneratedToken(t *testing.T) { + claims := JWTClaims{ + Subject: "user-42", + Issuer: "tester", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + Custom: map[string]any{"role": "admin"}, + } + token, err := JWTGenerate(claims, "super-secret") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + got, err := JWTValidate(token, "super-secret") + if err != nil { + t.Fatalf("JWTValidate error: %v", err) + } + if got.Subject != "user-42" { + t.Errorf("Subject = %q, esperado user-42", got.Subject) + } + if got.Issuer != "tester" { + t.Errorf("Issuer = %q, esperado tester", got.Issuer) + } + if got.Custom["role"] != "admin" { + t.Errorf("Custom[role] = %v, esperado admin", got.Custom["role"]) + } +} + +func TestJWTValidate_RejectsInvalidSignature(t *testing.T) { + token, err := JWTGenerate(JWTClaims{Subject: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()}, "secret-a") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + if _, err := JWTValidate(token, "secret-b"); err == nil { + t.Fatal("esperaba error con secret distinto") + } +} + +func TestJWTValidate_RejectsExpiredToken(t *testing.T) { + token, err := JWTGenerate(JWTClaims{ + Subject: "x", + ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), + }, "s") + if err != nil { + t.Fatalf("JWTGenerate error: %v", err) + } + if _, err := JWTValidate(token, "s"); err == nil { + t.Fatal("esperaba error con token expirado") + } +} + +func TestJWTValidate_RejectsMalformedToken(t *testing.T) { + cases := []string{ + "", + "not-a-token", + "one.two", + "one.two.three.four", + } + for _, tok := range cases { + if _, err := JWTValidate(tok, "s"); err == nil { + t.Errorf("esperaba error con token %q", tok) + } + } +} + +func TestJWTValidate_RejectsOtherAlgorithms(t *testing.T) { + // Token con alg=none no es aceptado aunque la firma sea vacia + // Construimos manualmente: header con alg=none + // "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0" = {"alg":"none","typ":"JWT"} + token := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ4In0." + if _, err := JWTValidate(token, "s"); err == nil { + t.Fatal("esperaba error con alg=none") + } + if !strings.Contains(token, ".") { + t.Fatal("token debe tener puntos") + } +} 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") + } +} diff --git a/functions/infra/oauth_config.go b/functions/infra/oauth_config.go new file mode 100644 index 00000000..0668739a --- /dev/null +++ b/functions/infra/oauth_config.go @@ -0,0 +1,12 @@ +package infra + +// OAuthConfig contiene la configuracion de un proveedor OAuth2. +// Los Scopes se concatenan con espacio al construir la URL de autorizacion. +type OAuthConfig struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + RedirectURL string `json:"redirect_url"` + Scopes []string `json:"scopes"` +} diff --git a/functions/infra/oauth_tokens.go b/functions/infra/oauth_tokens.go new file mode 100644 index 00000000..d4e38353 --- /dev/null +++ b/functions/infra/oauth_tokens.go @@ -0,0 +1,10 @@ +package infra + +// OAuthTokens contiene los tokens obtenidos de un flujo OAuth2. +// ExpiresAt es Unix epoch seconds calculado a partir de expires_in del proveedor. +type OAuthTokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresAt int64 `json:"expires_at"` +} diff --git a/functions/infra/password_hash.go b/functions/infra/password_hash.go new file mode 100644 index 00000000..45851c33 --- /dev/null +++ b/functions/infra/password_hash.go @@ -0,0 +1,16 @@ +package infra + +import "golang.org/x/crypto/bcrypt" + +// PasswordHash hashea un password con bcrypt. +// cost controla el trabajo computacional (4 = minimo, 14 = muy lento). Valor 0 usa default 12. +func PasswordHash(password string, cost int) (string, error) { + if cost <= 0 { + cost = 12 + } + b, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/functions/infra/password_hash.md b/functions/infra/password_hash.md new file mode 100644 index 00000000..e9174fdd --- /dev/null +++ b/functions/infra/password_hash.md @@ -0,0 +1,41 @@ +--- +name: password_hash +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func PasswordHash(password string, cost int) (string, error)" +description: "Hashea un password con bcrypt. Cost por defecto es 12 (si se pasa 0). El hash resultante incluye el salt y el cost embebidos." +tags: [password, hash, bcrypt, auth, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [golang.org/x/crypto/bcrypt] +params: + - name: password + desc: "password en texto plano a hashear" + - name: cost + desc: "coste bcrypt entre 4 y 14. 0 usa el default 12 (buen balance velocidad/seguridad en 2025)" +output: "hash bcrypt en formato $2a$... apto para guardar en BD y verificar con PasswordVerify" +tested: true +tests: ["hashea password con cost default", "hashea password con cost custom", "hashes distintos para mismo password (salt diferente)"] +test_file_path: "functions/infra/password_hash_test.go" +file_path: "functions/infra/password_hash.go" +--- + +## Ejemplo + +```go +hash, err := PasswordHash(inputPassword, 12) +if err != nil { + return err +} +db.Exec("INSERT INTO users (email, password_hash) VALUES (?, ?)", email, hash) +``` + +## Notas + +Impura — bcrypt usa entropia del OS para generar salt aleatorio en cada invocacion. El hash producido incluye el salt y el cost embebidos en el string (`$2a$12$salt...hash`), por lo que PasswordVerify no necesita el cost como parametro aparte. Cost 12 = ~250ms/hash en hardware moderno (2025): suficiente para bloquear ataques por fuerza bruta sin ser insoportable en el login. Para proteccion extra en servidores con mucho CPU disponible se puede subir a 14. diff --git a/functions/infra/password_hash_test.go b/functions/infra/password_hash_test.go new file mode 100644 index 00000000..86157b59 --- /dev/null +++ b/functions/infra/password_hash_test.go @@ -0,0 +1,34 @@ +package infra + +import ( + "strings" + "testing" +) + +func TestPasswordHash_DefaultCost(t *testing.T) { + hash, err := PasswordHash("hunter2", 0) + if err != nil { + t.Fatalf("PasswordHash error: %v", err) + } + if !strings.HasPrefix(hash, "$2") { + t.Errorf("hash no tiene prefijo bcrypt: %q", hash) + } +} + +func TestPasswordHash_CustomCost(t *testing.T) { + hash, err := PasswordHash("password", 4) // 4 = minimum, rapido para tests + if err != nil { + t.Fatalf("PasswordHash error: %v", err) + } + if hash == "" { + t.Fatal("hash vacio") + } +} + +func TestPasswordHash_DifferentSalts(t *testing.T) { + h1, _ := PasswordHash("same-password", 4) + h2, _ := PasswordHash("same-password", 4) + if h1 == h2 { + t.Fatal("los hashes deben diferir por salt distinto") + } +} diff --git a/functions/infra/password_verify.go b/functions/infra/password_verify.go new file mode 100644 index 00000000..67cc9d6a --- /dev/null +++ b/functions/infra/password_verify.go @@ -0,0 +1,9 @@ +package infra + +import "golang.org/x/crypto/bcrypt" + +// PasswordVerify compara un password en texto plano contra un hash bcrypt. +// Retorna nil si hacen match, error si no. +func PasswordVerify(password string, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} diff --git a/functions/infra/password_verify.md b/functions/infra/password_verify.md new file mode 100644 index 00000000..e58b958a --- /dev/null +++ b/functions/infra/password_verify.md @@ -0,0 +1,47 @@ +--- +name: password_verify +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func PasswordVerify(password string, hash string) error" +description: "Verifica un password en texto plano contra un hash bcrypt. Retorna nil si hacen match, error si no coinciden o si el hash es invalido." +tags: [password, verify, bcrypt, auth, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [golang.org/x/crypto/bcrypt] +params: + - name: password + desc: "password en texto plano a verificar" + - name: hash + desc: "hash bcrypt obtenido previamente de PasswordHash (guardado en BD)" +output: "nil si el password coincide con el hash; error si no coincide o hash invalido" +tested: true +tests: ["verifica password correcto", "rechaza password incorrecto", "rechaza hash malformado"] +test_file_path: "functions/infra/password_verify_test.go" +file_path: "functions/infra/password_verify.go" +--- + +## Ejemplo + +```go +row := db.QueryRow("SELECT password_hash FROM users WHERE email = ?", email) +var stored string +if err := row.Scan(&stored); err != nil { + HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"}) + return +} +if err := PasswordVerify(input, stored); err != nil { + HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"}) + return +} +// OK, emitir token +``` + +## Notas + +Impura — bcrypt.CompareHashAndPassword es constant-time internamente (mitiga timing attacks). En respuestas HTTP al usuario NO distinguir entre "email no existe" y "password incorrecto": ambos casos deben retornar el mismo mensaje generico para no filtrar existencia de cuentas. El error real se puede loguear internamente con log_info/log_warn sin problema. diff --git a/functions/infra/password_verify_test.go b/functions/infra/password_verify_test.go new file mode 100644 index 00000000..6d13bb1a --- /dev/null +++ b/functions/infra/password_verify_test.go @@ -0,0 +1,23 @@ +package infra + +import "testing" + +func TestPasswordVerify_CorrectPassword(t *testing.T) { + hash, _ := PasswordHash("correct-horse-battery-staple", 4) + if err := PasswordVerify("correct-horse-battery-staple", hash); err != nil { + t.Fatalf("esperaba nil, got %v", err) + } +} + +func TestPasswordVerify_WrongPassword(t *testing.T) { + hash, _ := PasswordHash("secret", 4) + if err := PasswordVerify("wrong", hash); err == nil { + t.Fatal("esperaba error con password incorrecto") + } +} + +func TestPasswordVerify_InvalidHash(t *testing.T) { + if err := PasswordVerify("x", "not-a-bcrypt-hash"); err == nil { + t.Fatal("esperaba error con hash invalido") + } +} diff --git a/functions/infra/permission.go b/functions/infra/permission.go new file mode 100644 index 00000000..3e801f7f --- /dev/null +++ b/functions/infra/permission.go @@ -0,0 +1,8 @@ +package infra + +// Permission representa una accion sobre un recurso. +// Ejemplo: {Resource: "users", Action: "delete"}. +type Permission struct { + Resource string `json:"resource"` + Action string `json:"action"` +} diff --git a/functions/infra/rbac_check.go b/functions/infra/rbac_check.go new file mode 100644 index 00000000..19076847 --- /dev/null +++ b/functions/infra/rbac_check.go @@ -0,0 +1,20 @@ +package infra + +// RBACCheck verifica si un rol tiene un permiso concreto. +// Busca el rol por Name en la slice de roles; si existe itera sus permisos +// y retorna true si alguno hace match exacto de Resource + Action. +// Funcion pura — evaluacion de datos en memoria, sin I/O. +func RBACCheck(roles []Role, roleName string, perm Permission) bool { + for _, r := range roles { + if r.Name != roleName { + continue + } + for _, p := range r.Permissions { + if p.Resource == perm.Resource && p.Action == perm.Action { + return true + } + } + return false + } + return false +} diff --git a/functions/infra/rbac_check.md b/functions/infra/rbac_check.md new file mode 100644 index 00000000..b4e4fbb6 --- /dev/null +++ b/functions/infra/rbac_check.md @@ -0,0 +1,44 @@ +--- +name: rbac_check +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func RBACCheck(roles []Role, roleName string, perm Permission) bool" +description: "Verifica si un rol tiene un permiso concreto. Busca el rol por Name e itera sus permisos buscando match exacto de Resource + Action. Funcion pura." +tags: [rbac, role, permission, check, auth, infra] +uses_functions: [] +uses_types: [Role_go_infra, Permission_go_infra] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: roles + desc: "slice de roles disponibles en el sistema, cada uno con su lista de permisos" + - name: roleName + desc: "nombre del rol a verificar (ej: admin, viewer). Comparacion exacta" + - name: perm + desc: "permiso requerido: par Resource + Action (ej: users/delete)" +output: "true si el rol existe y tiene el permiso; false si el rol no existe o no tiene el permiso" +tested: true +tests: ["true cuando el rol tiene el permiso", "false cuando no tiene el permiso", "false cuando el rol no existe", "match exacto de Resource y Action"] +test_file_path: "functions/infra/rbac_check_test.go" +file_path: "functions/infra/rbac_check.go" +--- + +## Ejemplo + +```go +roles := []Role{ + {Name: "admin", Permissions: []Permission{{Resource: "users", Action: "delete"}}}, + {Name: "viewer", Permissions: []Permission{{Resource: "users", Action: "read"}}}, +} +can := RBACCheck(roles, "admin", Permission{Resource: "users", Action: "delete"}) +// can == true +``` + +## Notas + +Pura — solo recorre slices, sin I/O. No hay herencia ni wildcards: un rol "admin" sin el permiso explicito `users/delete` no lo tiene. Esto es deliberado para que la matriz rol→permisos sea auditable con un grep. Para wildcards o herencia, construir la lista de roles expandida antes de pasarla (ej: funcion helper que toma {admin: [viewer, ...]} y expande). Si hay dos Roles con el mismo Name solo se evalua el primero — considerar invariante de unicidad al construir la lista. diff --git a/functions/infra/rbac_check_test.go b/functions/infra/rbac_check_test.go new file mode 100644 index 00000000..b6ae63c1 --- /dev/null +++ b/functions/infra/rbac_check_test.go @@ -0,0 +1,58 @@ +package infra + +import "testing" + +func makeRoles() []Role { + return []Role{ + { + Name: "admin", + Permissions: []Permission{ + {Resource: "users", Action: "read"}, + {Resource: "users", Action: "write"}, + {Resource: "users", Action: "delete"}, + }, + }, + { + Name: "viewer", + Permissions: []Permission{ + {Resource: "users", Action: "read"}, + }, + }, + } +} + +func TestRBACCheck_Granted(t *testing.T) { + roles := makeRoles() + if !RBACCheck(roles, "admin", Permission{Resource: "users", Action: "delete"}) { + t.Fatal("admin deberia tener users/delete") + } + if !RBACCheck(roles, "viewer", Permission{Resource: "users", Action: "read"}) { + t.Fatal("viewer deberia tener users/read") + } +} + +func TestRBACCheck_Denied(t *testing.T) { + roles := makeRoles() + if RBACCheck(roles, "viewer", Permission{Resource: "users", Action: "delete"}) { + t.Fatal("viewer NO deberia tener users/delete") + } +} + +func TestRBACCheck_UnknownRole(t *testing.T) { + roles := makeRoles() + if RBACCheck(roles, "ghost", Permission{Resource: "users", Action: "read"}) { + t.Fatal("rol inexistente no deberia tener permisos") + } +} + +func TestRBACCheck_ExactMatch(t *testing.T) { + roles := makeRoles() + // Resource distinto + if RBACCheck(roles, "admin", Permission{Resource: "billing", Action: "read"}) { + t.Fatal("admin no tiene billing/read") + } + // Action distinta + if RBACCheck(roles, "viewer", Permission{Resource: "users", Action: "write"}) { + t.Fatal("viewer no tiene users/write") + } +} diff --git a/functions/infra/rbac_middleware.go b/functions/infra/rbac_middleware.go new file mode 100644 index 00000000..6deef170 --- /dev/null +++ b/functions/infra/rbac_middleware.go @@ -0,0 +1,31 @@ +package infra + +import "net/http" + +// RBACMiddleware retorna un Middleware que verifica que el usuario autenticado +// tenga un permiso concreto. Requiere que JWTMiddleware se ejecute antes en la +// chain (lee las claims del context). El rol se extrae de claims.Custom["role"]. +// Responde 403 si el rol no tiene el permiso; 401 si no hay claims en el context. +func RBACMiddleware(roles []Role, required Permission) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := JWTClaimsFromContext(r.Context()) + if !ok { + HTTPErrorResponse(w, HTTPError{ + Status: http.StatusUnauthorized, Code: "no_claims", + Message: "rbac_middleware requiere jwt_middleware antes en la chain", + }) + return + } + roleName, _ := claims.Custom["role"].(string) + if roleName == "" || !RBACCheck(roles, roleName, required) { + HTTPErrorResponse(w, HTTPError{ + Status: http.StatusForbidden, Code: "forbidden", + Message: "no tienes permiso para esta accion", + }) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/functions/infra/rbac_middleware.md b/functions/infra/rbac_middleware.md new file mode 100644 index 00000000..4c24a247 --- /dev/null +++ b/functions/infra/rbac_middleware.md @@ -0,0 +1,44 @@ +--- +name: rbac_middleware +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func RBACMiddleware(roles []Role, required Permission) Middleware" +description: "Middleware HTTP que verifica que el usuario autenticado tenga un permiso concreto. Lee las claims del context (puestas por JWTMiddleware), extrae el rol de claims.Custom[role] y evalua con RBACCheck. Responde 403 si no tiene permiso, 401 si no hay claims." +tags: [rbac, auth, middleware, http, server, infra] +uses_functions: [rbac_check_go_infra, http_error_response_go_infra] +uses_types: [Role_go_infra, Permission_go_infra, Middleware_go_infra, HTTPError_go_infra] +returns: [Middleware_go_infra] +returns_optional: false +error_type: error_go_core +imports: [net/http] +params: + - name: roles + desc: "matriz de roles y sus permisos, usualmente hardcoded o leida de config al arrancar" + - name: required + desc: "permiso requerido para acceder al handler (Resource + Action)" +output: "Middleware que bloquea el request con 403 si el rol del usuario no tiene el permiso requerido" +tested: true +tests: ["pasa con rol que tiene el permiso", "403 con rol sin permiso", "403 con rol inexistente", "401 si no hay claims en el context"] +test_file_path: "functions/infra/rbac_middleware_test.go" +file_path: "functions/infra/rbac_middleware.go" +--- + +## Ejemplo + +```go +roles := []Role{ + {Name: "admin", Permissions: []Permission{{Resource: "users", Action: "delete"}}}, +} +adminProtected := HTTPMiddlewareChain( + JWTMiddleware(secret), + RBACMiddleware(roles, Permission{Resource: "users", Action: "delete"}), +) +mux.Handle("DELETE /api/users/{id}", adminProtected(deleteUserHandler)) +``` + +## Notas + +Impura — depende del estado del request y de JWTMiddleware. El rol se lee de `claims.Custom["role"]` como string: si la app usa otro campo (ej: `claims.Custom["roles"]` como slice) conviene crear un middleware variante. Si el usuario tiene multiples roles, extenderse iterando `RBACCheck` sobre cada uno. Orden en la chain: SIEMPRE JWTMiddleware antes — si se olvida, el middleware retorna 401 con code `no_claims` para facilitar debugging. No cachea la evaluacion: RBACCheck es O(roles*permisos) pero para listas pequeñas (<100) es despreciable. diff --git a/functions/infra/rbac_middleware_test.go b/functions/infra/rbac_middleware_test.go new file mode 100644 index 00000000..18da865a --- /dev/null +++ b/functions/infra/rbac_middleware_test.go @@ -0,0 +1,98 @@ +package infra + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func buildRBACRoles() []Role { + return []Role{ + {Name: "admin", Permissions: []Permission{{Resource: "users", Action: "delete"}}}, + {Name: "viewer", Permissions: []Permission{{Resource: "users", Action: "read"}}}, + } +} + +func buildAuthedRequest(t *testing.T, role, secret string) *http.Request { + t.Helper() + claims := JWTClaims{ + Subject: "user-1", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + Custom: map[string]any{"role": role}, + } + tok, err := JWTGenerate(claims, secret) + if err != nil { + t.Fatalf("JWTGenerate: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+tok) + return req +} + +func TestRBACMiddleware_PermissionGranted(t *testing.T) { + secret := "s" + chain := HTTPMiddlewareChain( + JWTMiddleware(secret), + RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "delete"}), + ) + var called bool + handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(200) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, buildAuthedRequest(t, "admin", secret)) + if rec.Code != 200 || !called { + t.Errorf("admin deberia pasar: status=%d called=%v", rec.Code, called) + } +} + +func TestRBACMiddleware_PermissionDenied(t *testing.T) { + secret := "s" + chain := HTTPMiddlewareChain( + JWTMiddleware(secret), + RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "delete"}), + ) + handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("viewer no deberia pasar") + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, buildAuthedRequest(t, "viewer", secret)) + if rec.Code != 403 { + t.Errorf("status = %d, esperaba 403", rec.Code) + } +} + +func TestRBACMiddleware_UnknownRole(t *testing.T) { + secret := "s" + chain := HTTPMiddlewareChain( + JWTMiddleware(secret), + RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "read"}), + ) + handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("rol ghost no deberia pasar") + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, buildAuthedRequest(t, "ghost", secret)) + if rec.Code != 403 { + t.Errorf("status = %d, esperaba 403", rec.Code) + } +} + +func TestRBACMiddleware_NoClaims(t *testing.T) { + // Sin JWTMiddleware delante → no hay claims en context + mw := RBACMiddleware(buildRBACRoles(), Permission{Resource: "users", Action: "read"}) + handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("no deberia ejecutarse") + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != 401 { + t.Errorf("status = %d, esperaba 401", rec.Code) + } +} diff --git a/functions/infra/role.go b/functions/infra/role.go new file mode 100644 index 00000000..93d54c20 --- /dev/null +++ b/functions/infra/role.go @@ -0,0 +1,8 @@ +package infra + +// Role agrupa permisos bajo un nombre. +// Ejemplo: {Name: "admin", Permissions: [{Resource:"users", Action:"delete"}, ...]}. +type Role struct { + Name string `json:"name"` + Permissions []Permission `json:"permissions"` +} diff --git a/functions/infra/session.go b/functions/infra/session.go new file mode 100644 index 00000000..38ce4d48 --- /dev/null +++ b/functions/infra/session.go @@ -0,0 +1,12 @@ +package infra + +// Session representa una sesion de usuario almacenada en SQLite. +// Token es un valor aleatorio opaco (32 bytes hex = 64 chars). +// ExpiresAt y CreatedAt son Unix epoch seconds. +type Session struct { + Token string `json:"token"` + UserID string `json:"user_id"` + ExpiresAt int64 `json:"expires_at"` + CreatedAt int64 `json:"created_at"` + Metadata map[string]any `json:"metadata,omitempty"` +} diff --git a/functions/infra/session_cleanup.go b/functions/infra/session_cleanup.go new file mode 100644 index 00000000..da61433f --- /dev/null +++ b/functions/infra/session_cleanup.go @@ -0,0 +1,24 @@ +package infra + +import ( + "database/sql" + "fmt" + "time" +) + +// SessionCleanup elimina todas las sesiones con expires_at < ahora. +// Retorna el numero de filas eliminadas. +func SessionCleanup(db *sql.DB) (int64, error) { + if db == nil { + return 0, fmt.Errorf("session_cleanup: db nil") + } + res, err := db.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now().Unix()) + if err != nil { + return 0, fmt.Errorf("session_cleanup: delete: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("session_cleanup: rows_affected: %w", err) + } + return n, nil +} diff --git a/functions/infra/session_cleanup.md b/functions/infra/session_cleanup.md new file mode 100644 index 00000000..dc76a8bc --- /dev/null +++ b/functions/infra/session_cleanup.md @@ -0,0 +1,47 @@ +--- +name: session_cleanup +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SessionCleanup(db *sql.DB) (int64, error)" +description: "Elimina todas las sesiones expiradas de la tabla sessions. Retorna el numero de filas eliminadas. Ejecutar periodicamente (cada N horas) para evitar acumulacion." +tags: [session, auth, sqlite, cleanup, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [database/sql, fmt, time] +params: + - name: db + desc: "conexion SQL abierta a la BD de la app" +output: "numero de sesiones eliminadas en esta ejecucion" +tested: true +tests: ["elimina solo sesiones expiradas, mantiene las activas"] +test_file_path: "functions/infra/session_test.go" +file_path: "functions/infra/session_cleanup.go" +--- + +## Ejemplo + +```go +// En un ticker background de la app +go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + n, err := SessionCleanup(db) + if err != nil { + log.Printf("cleanup error: %v", err) + continue + } + log.Printf("sesiones expiradas eliminadas: %d", n) + } +}() +``` + +## Notas + +Impura — DELETE en SQLite, lee `time.Now()`. DELETE con prepared statement evita SQL injection. Es seguro ejecutar en paralelo con SessionCreate/SessionValidate (SQLite tiene locking por default en WAL mode). Ejecutar cada 1h es suficiente para la mayoria de apps; si la tabla crece mucho considerar tambien un DELETE parcial con LIMIT. diff --git a/functions/infra/session_create.go b/functions/infra/session_create.go new file mode 100644 index 00000000..f14687e0 --- /dev/null +++ b/functions/infra/session_create.go @@ -0,0 +1,83 @@ +package infra + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "time" +) + +// sessionEnsureTable crea la tabla sessions si no existe. +// Columnas: token (PK), user_id, expires_at, created_at, metadata (JSON). +func sessionEnsureTable(db *sql.DB) error { + const stmt = ` +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +` + _, err := db.Exec(stmt) + return err +} + +// SessionCreate genera un token aleatorio de 32 bytes (64 hex chars) e +// inserta la sesion en la tabla sessions. Crea la tabla si no existe. +// Retorna la Session completa lista para devolver al cliente. +func SessionCreate(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error) { + var zero Session + if db == nil { + return zero, fmt.Errorf("session_create: db nil") + } + if userID == "" { + return zero, fmt.Errorf("session_create: user_id vacio") + } + if ttl <= 0 { + return zero, fmt.Errorf("session_create: ttl debe ser positivo") + } + if err := sessionEnsureTable(db); err != nil { + return zero, fmt.Errorf("session_create: crear tabla: %w", err) + } + + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return zero, fmt.Errorf("session_create: rand: %w", err) + } + token := hex.EncodeToString(raw) + + now := time.Now().Unix() + exp := now + int64(ttl.Seconds()) + + var metaJSON []byte + if metadata == nil { + metaJSON = []byte("{}") + } else { + b, err := json.Marshal(metadata) + if err != nil { + return zero, fmt.Errorf("session_create: marshal metadata: %w", err) + } + metaJSON = b + } + + _, err := db.Exec( + "INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)", + token, userID, exp, now, string(metaJSON), + ) + if err != nil { + return zero, fmt.Errorf("session_create: insert: %w", err) + } + + return Session{ + Token: token, + UserID: userID, + ExpiresAt: exp, + CreatedAt: now, + Metadata: metadata, + }, nil +} diff --git a/functions/infra/session_create.md b/functions/infra/session_create.md new file mode 100644 index 00000000..b46f87a8 --- /dev/null +++ b/functions/infra/session_create.md @@ -0,0 +1,51 @@ +--- +name: session_create +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SessionCreate(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error)" +description: "Crea una sesion SQLite con token aleatorio de 32 bytes hex (256 bits de entropia). Crea la tabla sessions si no existe. Retorna la Session lista para devolver al cliente." +tags: [session, auth, sqlite, token, infra] +uses_functions: [] +uses_types: [Session_go_infra] +returns: [Session_go_infra] +returns_optional: false +error_type: error_go_core +imports: [crypto/rand, database/sql, encoding/hex, encoding/json, fmt, time] +params: + - name: db + desc: "conexion SQL abierta a la BD de la app. No debe ser nil" + - name: userID + desc: "identificador del usuario al que pertenece la sesion. No vacio" + - name: ttl + desc: "tiempo de vida de la sesion (time.Duration). Debe ser positivo" + - name: metadata + desc: "datos libres a persistir junto a la sesion (role, email, ip, etc.). Puede ser nil" +output: "Session con token opaco listo para devolver al cliente via header o cookie" +tested: true +tests: ["crea sesion y persiste en BD", "genera tokens distintos", "rechaza db nil", "rechaza user_id vacio"] +test_file_path: "functions/infra/session_test.go" +file_path: "functions/infra/session_create.go" +--- + +## Ejemplo + +```go +db, _ := sql.Open("sqlite3", "app.db") +session, err := SessionCreate(db, user.ID, 24*time.Hour, map[string]any{ + "email": user.Email, + "role": "admin", + "ip": r.RemoteAddr, +}) +if err != nil { + HTTPErrorResponse(w, HTTPError{Status: 500, Code: "session_error", Message: err.Error()}) + return +} +HTTPJSONResponse(w, 200, map[string]string{"token": session.Token}) +``` + +## Notas + +Impura — hace I/O en SQLite, usa entropia del OS con `crypto/rand`, y lee el tiempo con `time.Now()`. La tabla `sessions` se crea con `CREATE TABLE IF NOT EXISTS` (PK token, indices en user_id y expires_at) para que la primera llamada deje todo listo sin setup manual. Token es 32 bytes aleatorios codificados en hex = 64 chars, 256 bits de entropia. Session fixation mitigada: el token lo genera el servidor, no se acepta del cliente. Para invalidar una sesion: `DELETE FROM sessions WHERE token = ?`. Limpieza periodica de expiradas: SessionCleanup. diff --git a/functions/infra/session_test.go b/functions/infra/session_test.go new file mode 100644 index 00000000..b8c0a911 --- /dev/null +++ b/functions/infra/session_test.go @@ -0,0 +1,126 @@ +package infra + +import ( + "database/sql" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func openSessionTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func TestSessionCreate_PersistsSession(t *testing.T) { + db := openSessionTestDB(t) + s, err := SessionCreate(db, "user-1", time.Hour, map[string]any{"role": "admin"}) + if err != nil { + t.Fatalf("SessionCreate: %v", err) + } + if s.Token == "" || len(s.Token) != 64 { + t.Errorf("token de largo incorrecto: len=%d", len(s.Token)) + } + if s.UserID != "user-1" { + t.Errorf("UserID = %q", s.UserID) + } + if s.ExpiresAt <= s.CreatedAt { + t.Errorf("ExpiresAt %d <= CreatedAt %d", s.ExpiresAt, s.CreatedAt) + } + + // Verificar persistencia + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&count); err != nil { + t.Fatalf("query: %v", err) + } + if count != 1 { + t.Errorf("esperaba 1 fila, got %d", count) + } +} + +func TestSessionCreate_GeneratesDistinctTokens(t *testing.T) { + db := openSessionTestDB(t) + s1, _ := SessionCreate(db, "u", time.Hour, nil) + s2, _ := SessionCreate(db, "u", time.Hour, nil) + if s1.Token == s2.Token { + t.Fatal("dos sesiones no pueden tener el mismo token") + } +} + +func TestSessionCreate_RejectsEmptyUserID(t *testing.T) { + db := openSessionTestDB(t) + if _, err := SessionCreate(db, "", time.Hour, nil); err == nil { + t.Fatal("esperaba error con user_id vacio") + } +} + +func TestSessionValidate_ValidSession(t *testing.T) { + db := openSessionTestDB(t) + s, _ := SessionCreate(db, "user-7", time.Hour, map[string]any{"k": "v"}) + + got, err := SessionValidate(db, s.Token) + if err != nil { + t.Fatalf("SessionValidate: %v", err) + } + if got.UserID != "user-7" { + t.Errorf("UserID = %q", got.UserID) + } + if got.Metadata["k"] != "v" { + t.Errorf("metadata[k] = %v", got.Metadata["k"]) + } +} + +func TestSessionValidate_MissingToken(t *testing.T) { + db := openSessionTestDB(t) + _ = sessionEnsureTable(db) + if _, err := SessionValidate(db, "nope"); err == nil { + t.Fatal("esperaba error con token inexistente") + } +} + +func TestSessionValidate_ExpiredSession(t *testing.T) { + db := openSessionTestDB(t) + _ = sessionEnsureTable(db) + // Insertar manualmente una sesion ya expirada + past := time.Now().Unix() - 60 + _, err := db.Exec( + "INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)", + "expired-tok", "u", past, past-100, "{}", + ) + if err != nil { + t.Fatalf("insert: %v", err) + } + if _, err := SessionValidate(db, "expired-tok"); err == nil { + t.Fatal("esperaba error con sesion expirada") + } +} + +func TestSessionCleanup_RemovesOnlyExpired(t *testing.T) { + db := openSessionTestDB(t) + active, _ := SessionCreate(db, "active-user", time.Hour, nil) + _ = sessionEnsureTable(db) + past := time.Now().Unix() - 60 + _, _ = db.Exec( + "INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)", + "expired", "u", past, past, "{}", + ) + + n, err := SessionCleanup(db) + if err != nil { + t.Fatalf("SessionCleanup: %v", err) + } + if n != 1 { + t.Errorf("esperaba 1 eliminada, got %d", n) + } + + // La activa sigue ahi + if _, err := SessionValidate(db, active.Token); err != nil { + t.Errorf("sesion activa no deberia haberse borrado: %v", err) + } +} diff --git a/functions/infra/session_validate.go b/functions/infra/session_validate.go new file mode 100644 index 00000000..d190efbe --- /dev/null +++ b/functions/infra/session_validate.go @@ -0,0 +1,58 @@ +package infra + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" +) + +// SessionValidate busca el token en la tabla sessions, verifica que no este +// expirado y retorna la Session con su metadata deserializada. +// Error si el token no existe, esta expirado o la BD falla. +func SessionValidate(db *sql.DB, token string) (Session, error) { + var zero Session + if db == nil { + return zero, fmt.Errorf("session_validate: db nil") + } + if token == "" { + return zero, fmt.Errorf("session_validate: token vacio") + } + + var ( + userID string + expiresAt int64 + createdAt int64 + metaStr string + ) + row := db.QueryRow( + "SELECT user_id, expires_at, created_at, metadata FROM sessions WHERE token = ?", + token, + ) + if err := row.Scan(&userID, &expiresAt, &createdAt, &metaStr); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return zero, fmt.Errorf("session_validate: token no encontrado") + } + return zero, fmt.Errorf("session_validate: scan: %w", err) + } + + if time.Now().Unix() >= expiresAt { + return zero, fmt.Errorf("session_validate: sesion expirada") + } + + var metadata map[string]any + if metaStr != "" { + if err := json.Unmarshal([]byte(metaStr), &metadata); err != nil { + return zero, fmt.Errorf("session_validate: metadata malformada: %w", err) + } + } + + return Session{ + Token: token, + UserID: userID, + ExpiresAt: expiresAt, + CreatedAt: createdAt, + Metadata: metadata, + }, nil +} diff --git a/functions/infra/session_validate.md b/functions/infra/session_validate.md new file mode 100644 index 00000000..7ccf1f75 --- /dev/null +++ b/functions/infra/session_validate.md @@ -0,0 +1,43 @@ +--- +name: session_validate +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SessionValidate(db *sql.DB, token string) (Session, error)" +description: "Busca un token en la tabla sessions, verifica que no este expirado y retorna la Session con metadata deserializada. Error si no existe o expirado." +tags: [session, auth, sqlite, validate, infra] +uses_functions: [] +uses_types: [Session_go_infra] +returns: [Session_go_infra] +returns_optional: false +error_type: error_go_core +imports: [database/sql, encoding/json, errors, fmt, time] +params: + - name: db + desc: "conexion SQL abierta a la BD de la app" + - name: token + desc: "token opaco recibido del cliente (ej: header X-Session-Token o cookie)" +output: "Session valida con user_id, metadata y timestamps. Error si token invalido o expirado" +tested: true +tests: ["valida token existente no expirado", "rechaza token inexistente", "rechaza token expirado"] +test_file_path: "functions/infra/session_test.go" +file_path: "functions/infra/session_validate.go" +--- + +## Ejemplo + +```go +token := r.Header.Get("X-Session-Token") +session, err := SessionValidate(db, token) +if err != nil { + HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_session", Message: "sesion invalida o expirada"}) + return +} +role, _ := session.Metadata["role"].(string) +``` + +## Notas + +Impura — I/O en SQLite, lee `time.Now()` para validar expiracion. Query con prepared statement (`?` placeholder) para evitar SQL injection. En respuestas HTTP al cliente no distinguir entre "token no existe" y "expirado" para no filtrar informacion. Si necesitas invalidar la sesion tras validar (ej: renovar token): `DELETE FROM sessions WHERE token = ?` en el mismo handler. diff --git a/go.mod b/go.mod index cef851cf..9ced0b9e 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,10 @@ require ( github.com/jackc/pgx/v5 v5.9.1 github.com/marcboeker/go-duckdb v1.8.5 github.com/mattn/go-sqlite3 v1.14.37 - golang.org/x/sync v0.19.0 + golang.org/x/crypto v0.48.0 + golang.org/x/sync v0.20.0 gopkg.in/yaml.v3 v3.0.1 + nhooyr.io/websocket v1.8.17 ) require ( @@ -59,10 +61,10 @@ require ( go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.29.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - nhooyr.io/websocket v1.8.17 // indirect ) diff --git a/go.sum b/go.sum index c5872d02..c2c26744 100644 --- a/go.sum +++ b/go.sum @@ -155,12 +155,14 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -170,8 +172,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -180,21 +182,23 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/types/infra/jwt_claims.md b/types/infra/jwt_claims.md new file mode 100644 index 00000000..dfd99b2e --- /dev/null +++ b/types/infra/jwt_claims.md @@ -0,0 +1,39 @@ +--- +name: JWTClaims +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type JWTClaims struct { + Subject string `json:"sub"` + Issuer string `json:"iss,omitempty"` + Audience string `json:"aud,omitempty"` + ExpiresAt int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Custom map[string]any `json:"custom,omitempty"` + } +description: "Claims de un JSON Web Token. Incluye los campos registrados (sub, iss, aud, exp, iat) y un mapa Custom libre para claims de aplicacion como role o email." +tags: [jwt, auth, token, claims, infra] +uses_types: [] +file_path: "functions/infra/jwt_claims.go" +--- + +## Ejemplo + +```go +claims := JWTClaims{ + Subject: "user-123", + Issuer: "my-api", + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + Custom: map[string]any{ + "role": "admin", + "email": "alice@example.com", + }, +} +token, _ := JWTGenerate(claims, secret) +``` + +## Notas + +Tipo producto — los campos estandar cubren RFC 7519. ExpiresAt y IssuedAt son Unix epoch seconds. JWTGenerate setea IssuedAt automaticamente si viene en cero. Custom se serializa bajo la clave "custom" en el payload JSON para evitar colisiones con claims registrados. Para leer valores custom de forma segura tras JWTValidate: `v, ok := claims.Custom["role"].(string)`. diff --git a/types/infra/oauth_config.md b/types/infra/oauth_config.md new file mode 100644 index 00000000..944be6ba --- /dev/null +++ b/types/infra/oauth_config.md @@ -0,0 +1,38 @@ +--- +name: OAuthConfig +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type OAuthConfig struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + RedirectURL string `json:"redirect_url"` + Scopes []string `json:"scopes"` + } +description: "Configuracion de un proveedor OAuth2. Contiene credenciales del cliente, endpoints de autorizacion/token, redirect URI y scopes solicitados." +tags: [oauth, oauth2, auth, config, infra] +uses_types: [] +file_path: "functions/infra/oauth_config.go" +--- + +## Ejemplo + +```go +google := OAuthConfig{ + ClientID: os.Getenv("GOOGLE_CLIENT_ID"), + ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + RedirectURL: "http://localhost:8080/callback", + Scopes: []string{"openid", "email", "profile"}, +} +url := Oauth2AuthURL(google, "random-state-123") +``` + +## Notas + +Tipo producto — agrupa todo lo necesario para los tres flujos OAuth2 soportados (auth URL, code exchange, refresh). ClientSecret nunca debe salir del servidor: Oauth2Exchange y Oauth2Refresh lo envian al TokenURL del proveedor, no al cliente. Scopes se concatenan con espacio al construir la URL (`openid email profile`). diff --git a/types/infra/oauth_tokens.md b/types/infra/oauth_tokens.md new file mode 100644 index 00000000..13d6fec0 --- /dev/null +++ b/types/infra/oauth_tokens.md @@ -0,0 +1,32 @@ +--- +name: OAuthTokens +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type OAuthTokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresAt int64 `json:"expires_at"` + } +description: "Tokens OAuth2 obtenidos de un flujo de autorizacion. AccessToken es el de corta vida para llamadas a APIs. RefreshToken renueva el access. ExpiresAt es Unix epoch seconds." +tags: [oauth, oauth2, token, infra] +uses_types: [] +file_path: "functions/infra/oauth_tokens.go" +--- + +## Ejemplo + +```go +tokens, _ := Oauth2Exchange(googleConfig, code) +if time.Now().Unix() >= tokens.ExpiresAt { + tokens, _ = Oauth2Refresh(googleConfig, tokens.RefreshToken) +} +req.Header.Set("Authorization", tokens.TokenType + " " + tokens.AccessToken) +``` + +## Notas + +Tipo producto — producto del flujo OAuth2. AccessToken tipicamente expira en 1h. RefreshToken puede durar dias/meses segun el proveedor. TokenType suele ser "Bearer". ExpiresAt se calcula como `time.Now().Unix() + expires_in` al parsear la respuesta del proveedor, asi el consumidor solo compara con `time.Now().Unix()` para saber si renovar. diff --git a/types/infra/permission.md b/types/infra/permission.md new file mode 100644 index 00000000..ed9ec25c --- /dev/null +++ b/types/infra/permission.md @@ -0,0 +1,29 @@ +--- +name: Permission +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type Permission struct { + Resource string `json:"resource"` + Action string `json:"action"` + } +description: "Permiso RBAC: accion sobre un recurso. Par (resource, action) evaluado por RBACCheck contra los permisos de un rol." +tags: [rbac, permission, auth, infra] +uses_types: [] +file_path: "functions/infra/permission.go" +--- + +## Ejemplo + +```go +p := Permission{Resource: "users", Action: "delete"} +if RBACCheck(roles, "admin", p) { + // el rol admin puede borrar usuarios +} +``` + +## Notas + +Tipo producto — Resource y Action son strings libres, decididos por la app. Convencion: Resource en plural snake_case (`users`, `articles`, `billing_invoices`), Action verbo minusculas (`read`, `write`, `delete`, `admin`). Los wildcards `*` no se interpretan — si quieres "todas las acciones" define una Permission explicita por cada una en el rol, o crea un rol superadmin fuera del sistema RBAC. diff --git a/types/infra/role.md b/types/infra/role.md new file mode 100644 index 00000000..74755ea6 --- /dev/null +++ b/types/infra/role.md @@ -0,0 +1,41 @@ +--- +name: Role +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type Role struct { + Name string `json:"name"` + Permissions []Permission `json:"permissions"` + } +description: "Rol RBAC con nombre y lista de permisos. Los roles son datos puros — cada app decide donde los almacena (hardcoded, JSON, SQLite)." +tags: [rbac, role, auth, infra] +uses_types: [Permission_go_infra] +file_path: "functions/infra/role.go" +--- + +## Ejemplo + +```go +roles := []Role{ + { + Name: "admin", + Permissions: []Permission{ + {Resource: "users", Action: "read"}, + {Resource: "users", Action: "write"}, + {Resource: "users", Action: "delete"}, + }, + }, + { + Name: "viewer", + Permissions: []Permission{ + {Resource: "users", Action: "read"}, + }, + }, +} +``` + +## Notas + +Tipo producto — Name identifica el rol por nombre (coincide con el valor de `claims.Custom["role"]` para RBACMiddleware). Permissions es la lista completa de acciones que el rol puede realizar. No hay herencia entre roles: si admin debe tener todo lo de viewer, hay que incluir esos permisos explicitamente en la definicion de admin. Esta es una decision consciente para mantener la evaluacion lineal y predecible. diff --git a/types/infra/session.md b/types/infra/session.md new file mode 100644 index 00000000..8e013dbd --- /dev/null +++ b/types/infra/session.md @@ -0,0 +1,34 @@ +--- +name: Session +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type Session struct { + Token string `json:"token"` + UserID string `json:"user_id"` + ExpiresAt int64 `json:"expires_at"` + CreatedAt int64 `json:"created_at"` + Metadata map[string]any `json:"metadata,omitempty"` + } +description: "Sesion de usuario almacenada en SQLite. Alternativa server-side a JWT para apps con estado. Token es 32 bytes hex opacos generados con crypto/rand." +tags: [session, auth, sqlite, token, infra] +uses_types: [] +file_path: "functions/infra/session.go" +--- + +## Ejemplo + +```go +session, _ := SessionCreate(db, "user-123", 24*time.Hour, map[string]any{ + "role": "admin", + "email": "alice@example.com", +}) +// session.Token es el valor opaco a enviar al cliente +w.Header().Set("X-Session-Token", session.Token) +``` + +## Notas + +Tipo producto — Token identifica la sesion en la tabla SQLite. UserID vincula la sesion al usuario. ExpiresAt y CreatedAt son Unix epoch seconds. Metadata es un mapa libre que se serializa a JSON en la columna metadata de la tabla sessions. A diferencia de JWT, Session requiere una query a la BD para validar en cada request — a cambio permite invalidacion inmediata (DELETE FROM sessions WHERE token = ?).