--- id: "0010" title: "Auth System" status: completado type: feature domain: [] scope: multi-app priority: alta depends: [] blocks: [] related: [] created: 2026-05-17 updated: 2026-05-17 tags: [] --- # 0010 — Auth System ## Metadata | Campo | Valor | |-------|-------| | **ID** | 0010 | | **Estado** | pendiente | | **Prioridad** | alta | | **Tipo** | feature | ## Dependencias Depende de 0009 (HTTP Server Foundation) para los middleware de auth (`Middleware` type, `http_middleware_chain`, `http_error_response`). --- ## Objetivo Crear funciones reutilizables de autenticacion y autorizacion en Go (dominio infra) cubriendo JWT, hashing de passwords, sesiones SQLite, cliente OAuth2 y checks RBAC, de forma que cualquier app nueva del registry pueda tener auth completo componiendo primitivas en vez de implementarlo desde cero. ## Contexto - Actualmente el registry tiene **CERO funciones de auth genericas**. Solo existe `auth_form_ts_ui` (componente UI de login) y funciones de auth especificas para Metabase (`metabase_auth_go_infra`, `metabase_auth_py_infra`). - Existen funciones de password store (`pass_get`, `pass_set`, `pass_generate` en Bash) pero son wrappers de `pass`, no primitivas de hashing/verificacion. - `generate_password_bash_cybersecurity` genera passwords pero no los hashea. - Cada vez que una app necesita auth (como `sqlite_api` o `deploy_server`), hay que implementar JWT, sesiones y validacion ad-hoc. - Con estas funciones, una app nueva solo hace: `password_hash` al registrar, `jwt_generate` al login, `jwt_middleware` en las rutas protegidas, y opcionalmente `rbac_middleware` para permisos granulares. - Las funciones HTTP server de 0009 (`Middleware` type, `http_middleware_chain`) son el punto de integracion natural para los middlewares de auth. ## Arquitectura ``` functions/infra/ ├── jwt_generate.go — NEW: genera JWT firmado desde claims + secret ├── jwt_generate.md — NEW ├── jwt_validate.go — NEW: valida JWT, retorna claims o error ├── jwt_validate.md — NEW ├── jwt_middleware.go — NEW: middleware HTTP que valida JWT del header Authorization ├── jwt_middleware.md — NEW ├── password_hash.go — NEW: hashea password con bcrypt ├── password_hash.md — NEW ├── password_verify.go — NEW: verifica password contra hash bcrypt ├── password_verify.md — NEW ├── session_create.go — NEW: crea sesion en SQLite, retorna token ├── session_create.md — NEW ├── session_validate.go — NEW: valida token de sesion, retorna datos de usuario ├── session_validate.md — NEW ├── session_cleanup.go — NEW: elimina sesiones expiradas ├── session_cleanup.md — NEW ├── oauth2_auth_url.go — NEW: construye URL de autorizacion OAuth2 ├── oauth2_auth_url.md — NEW ├── oauth2_exchange.go — NEW: intercambia auth code por tokens ├── oauth2_exchange.md — NEW ├── oauth2_refresh.go — NEW: renueva access token con refresh token ├── oauth2_refresh.md — NEW ├── rbac_check.go — NEW: verifica si un rol tiene un permiso (datos puros) ├── rbac_check.md — NEW ├── rbac_middleware.go — NEW: middleware HTTP que verifica permisos tras auth ├── rbac_middleware.md — NEW types/infra/ ├── jwt_claims.md — NEW: metadata del tipo JWTClaims ├── session.md — NEW: metadata del tipo Session ├── oauth_config.md — NEW: metadata del tipo OAuthConfig ├── oauth_tokens.md — NEW: metadata del tipo OAuthTokens ├── permission.md — NEW: metadata del tipo Permission ├── role.md — NEW: metadata del tipo Role ``` ### Patron pure core / impure shell - **Pure:** `oauth2_auth_url` (construye string URL sin I/O), `rbac_check` (evaluacion de datos en memoria) - **Impure:** todo lo demas — JWT usa `time.Now()` para expiracion/validacion, bcrypt hace trabajo criptografico con entropia del OS, sesiones interactuan con SQLite, OAuth2 hace HTTP requests, middlewares interactuan con `http.Request`/`http.ResponseWriter` ## Diseno ### Tipos ```go // JWTClaims contiene claims estandar y custom para un JWT 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"` } // Session representa una sesion de usuario almacenada en SQLite 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"` } // OAuthConfig contiene la configuracion de un proveedor OAuth2 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"` } // OAuthTokens contiene los tokens obtenidos de un flujo OAuth2 type OAuthTokens struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresAt int64 `json:"expires_at"` } // Permission representa una accion sobre un recurso type Permission struct { Resource string `json:"resource"` Action string `json:"action"` } // Role agrupa permisos bajo un nombre type Role struct { Name string `json:"name"` Permissions []Permission `json:"permissions"` } ``` ### Funciones | Funcion | Purity | Firma (simplificada) | |---------|--------|---------------------| | `jwt_generate` | impure | `(claims JWTClaims, secret string) (string, error)` | | `jwt_validate` | impure | `(token string, secret string) (JWTClaims, error)` | | `jwt_middleware` | impure | `(secret string) Middleware` | | `password_hash` | impure | `(password string, cost int) (string, error)` | | `password_verify` | impure | `(password string, hash string) error` | | `session_create` | impure | `(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error)` | | `session_validate` | impure | `(db *sql.DB, token string) (Session, error)` | | `session_cleanup` | impure | `(db *sql.DB) (int64, error)` | | `oauth2_auth_url` | pure | `(config OAuthConfig, state string) string` | | `oauth2_exchange` | impure | `(config OAuthConfig, code string) (OAuthTokens, error)` | | `oauth2_refresh` | impure | `(config OAuthConfig, refreshToken string) (OAuthTokens, error)` | | `rbac_check` | pure | `(roles []Role, roleName string, perm Permission) bool` | | `rbac_middleware` | impure | `(roles []Role, requiredPerm Permission) Middleware` | ## Tareas ### Fase 1: Tipos - [ ] **1.1** Crear tipo `JWTClaims` en `functions/infra/jwt_claims.go` con `.md` en `types/infra/jwt_claims.md` - [ ] **1.2** Crear tipo `Session` en `functions/infra/session.go` con `.md` en `types/infra/session.md` - [ ] **1.3** Crear tipo `OAuthConfig` en `functions/infra/oauth_config.go` con `.md` en `types/infra/oauth_config.md` - [ ] **1.4** Crear tipo `OAuthTokens` en `functions/infra/oauth_tokens.go` con `.md` en `types/infra/oauth_tokens.md` - [ ] **1.5** Crear tipo `Permission` en `functions/infra/permission.go` con `.md` en `types/infra/permission.md` - [ ] **1.6** Crear tipo `Role` en `functions/infra/role.go` con `.md` en `types/infra/role.md` ### Fase 2: JWT + Password (auth core) - [ ] **2.1** `jwt_generate` — codifica header+payload en base64url, firma con HMAC-SHA256, retorna token string. Setea `iat` automaticamente si no viene en claims. - [ ] **2.2** `jwt_validate` — split por `.`, verifica firma HMAC-SHA256, decodifica claims, valida `exp` contra `time.Now()`. Error descriptivo si firma invalida, expirado, o malformado. - [ ] **2.3** `password_hash` — wrapper de `golang.org/x/crypto/bcrypt.GenerateFromPassword`. Cost por defecto: 12. - [ ] **2.4** `password_verify` — wrapper de `bcrypt.CompareHashAndPassword`. Retorna nil si match, error si no. ### Fase 3: Sesiones SQLite - [ ] **3.1** `session_create` — genera token con `crypto/rand` (32 bytes hex), inserta en tabla `sessions` (la funcion crea la tabla si no existe via `CREATE TABLE IF NOT EXISTS`), retorna `Session`. - [ ] **3.2** `session_validate` — busca token en tabla `sessions`, verifica que no este expirado, retorna `Session` o error. - [ ] **3.3** `session_cleanup` — `DELETE FROM sessions WHERE expires_at < ?` con timestamp actual. Retorna cantidad de filas eliminadas. ### Fase 4: OAuth2 - [ ] **4.1** `oauth2_auth_url` — construye URL con query params: `client_id`, `redirect_uri`, `response_type=code`, `scope`, `state`. Funcion pura, solo concatena strings. - [ ] **4.2** `oauth2_exchange` — POST a `token_url` con `grant_type=authorization_code`, `code`, `client_id`, `client_secret`, `redirect_uri`. Parsea JSON response a `OAuthTokens`. - [ ] **4.3** `oauth2_refresh` — POST a `token_url` con `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`. Parsea JSON response a `OAuthTokens`. ### Fase 5: RBAC + Middlewares - [ ] **5.1** `rbac_check` — busca el rol por nombre en `[]Role`, itera sus permisos, retorna `true` si encuentra match de `resource` + `action`. Funcion pura. - [ ] **5.2** `jwt_middleware` — extrae token del header `Authorization: Bearer `, valida con `jwt_validate`, inyecta claims en `r.Context()` con `context.WithValue`, llama `next.ServeHTTP`. Retorna 401 si falta token o es invalido. Usa tipo `Middleware` de 0009. - [ ] **5.3** `rbac_middleware` — extrae claims del context (puestas por `jwt_middleware`), lee el rol del campo `custom["role"]`, evalua con `rbac_check`. Retorna 403 si no tiene permiso. Requiere que `jwt_middleware` se ejecute antes en la chain. ### Fase 6: Tests y cleanup - [ ] **6.1** Tests para JWT: generar token, validar token valido, rechazar token expirado, rechazar firma invalida - [ ] **6.2** Tests para password: hash y verify OK, verify con password incorrecto falla - [ ] **6.3** Tests para sesiones: crear, validar, validar expirada, cleanup - [ ] **6.4** Tests para OAuth2: `oauth2_auth_url` genera URL correcta (test puro), exchange/refresh con `httptest.NewServer` mock - [ ] **6.5** Tests para RBAC: check con permiso, sin permiso, rol inexistente - [ ] **6.6** Tests para middlewares: jwt_middleware con token valido/invalido/ausente, rbac_middleware con/sin permiso - [ ] **6.7** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db - [ ] **6.8** Verificar `go vet -tags fts5` y `go test -tags fts5 ./functions/infra/` --- ## Ejemplo de uso ### Registro de usuario y login con JWT ```go // Handler de registro func registerHandler(w http.ResponseWriter, r *http.Request) { var input struct { Email string `json:"email"` Password string `json:"password"` } if err := infra.HttpParseBody(r, &input, 1<<20); err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()}) return } hash, err := infra.PasswordHash(input.Password, 12) if err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "hash_error", Message: err.Error()}) return } // Guardar email + hash en la BD de la app... userID := saveUser(input.Email, hash) infra.HttpJsonResponse(w, 201, map[string]string{"id": userID}) } // Handler de login func loginHandler(w http.ResponseWriter, r *http.Request) { var input struct { Email string `json:"email"` Password string `json:"password"` } if err := infra.HttpParseBody(r, &input, 1<<20); err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()}) return } user, err := findUserByEmail(input.Email) if err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"}) return } if err := infra.PasswordVerify(input.Password, user.PasswordHash); err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"}) return } claims := infra.JWTClaims{ Subject: user.ID, ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), Custom: map[string]any{"role": "admin", "email": user.Email}, } token, err := infra.JwtGenerate(claims, os.Getenv("JWT_SECRET")) if err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "token_error", Message: err.Error()}) return } infra.HttpJsonResponse(w, 200, map[string]string{"token": token}) } ``` ### Rutas protegidas con JWT + RBAC ```go // Definir roles y permisos como datos roles := []infra.Role{ { Name: "admin", Permissions: []infra.Permission{ {Resource: "users", Action: "read"}, {Resource: "users", Action: "write"}, {Resource: "users", Action: "delete"}, }, }, { Name: "viewer", Permissions: []infra.Permission{ {Resource: "users", Action: "read"}, }, }, } // Rutas publicas publicRoutes := []infra.Route{ {Method: "POST", Path: "/register", Handler: registerHandler}, {Method: "POST", Path: "/login", Handler: loginHandler}, } // Rutas protegidas con JWT protectedRoutes := []infra.Route{ {Method: "GET", Path: "/api/me", Handler: meHandler}, } // Rutas protegidas con JWT + RBAC adminRoutes := []infra.Route{ {Method: "DELETE", Path: "/api/users/{id}", Handler: deleteUserHandler}, } secret := os.Getenv("JWT_SECRET") mux := http.NewServeMux() // Publicas: sin middleware de auth for _, r := range publicRoutes { mux.HandleFunc(r.Method+" "+r.Path, r.Handler) } // Protegidas: JWT middleware jwtProtected := infra.HttpMiddlewareChain( infra.JwtMiddleware(secret), ) for _, r := range protectedRoutes { mux.Handle(r.Method+" "+r.Path, jwtProtected(http.HandlerFunc(r.Handler))) } // Admin: JWT + RBAC adminProtected := infra.HttpMiddlewareChain( infra.JwtMiddleware(secret), infra.RbacMiddleware(roles, infra.Permission{Resource: "users", Action: "delete"}), ) for _, r := range adminRoutes { mux.Handle(r.Method+" "+r.Path, adminProtected(http.HandlerFunc(r.Handler))) } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() infra.HttpServe(":8080", mux, ctx) ``` ### Flujo OAuth2 (ej. Google) ```go googleConfig := infra.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"}, } // 1. Redirigir al usuario al proveedor func oauthLoginHandler(w http.ResponseWriter, r *http.Request) { state := generateRandomState() // guardar en cookie/session para validar despues url := infra.Oauth2AuthUrl(googleConfig, state) http.Redirect(w, r, url, http.StatusTemporaryRedirect) } // 2. Callback: intercambiar code por tokens func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") // Validar state contra el guardado en cookie/session... tokens, err := infra.Oauth2Exchange(googleConfig, code) if err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "oauth_error", Message: err.Error()}) return } // Usar tokens.AccessToken para obtener datos del usuario... // Crear sesion local o JWT propio infra.HttpJsonResponse(w, 200, map[string]string{"access_token": tokens.AccessToken}) } // 3. Renovar token cuando expire func refreshHandler(w http.ResponseWriter, r *http.Request) { refreshToken := extractRefreshToken(r) newTokens, err := infra.Oauth2Refresh(googleConfig, refreshToken) if err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "refresh_failed", Message: err.Error()}) return } infra.HttpJsonResponse(w, 200, newTokens) } ``` ### Sesiones SQLite (alternativa a JWT para apps con estado) ```go db, _ := sql.Open("sqlite3", "app.db?_journal_mode=wal") // Crear sesion al login func sessionLoginHandler(w http.ResponseWriter, r *http.Request) { // ... validar credenciales ... session, err := infra.SessionCreate(db, user.ID, 24*time.Hour, map[string]any{ "email": user.Email, "role": "admin", }) if err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "session_error", Message: err.Error()}) return } infra.HttpJsonResponse(w, 200, map[string]string{"session_token": session.Token}) } // Validar sesion en cada request func protectedHandler(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("X-Session-Token") session, err := infra.SessionValidate(db, token) if err != nil { infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_session", Message: "sesion invalida o expirada"}) return } infra.HttpJsonResponse(w, 200, map[string]any{"user_id": session.UserID, "metadata": session.Metadata}) } // Limpiar sesiones expiradas periodicamente deleted, _ := infra.SessionCleanup(db) fmt.Printf("sesiones eliminadas: %d\n", deleted) ``` ## Decisiones de diseno - **bcrypt sobre argon2:** bcrypt esta en `golang.org/x/crypto` (dependencia semi-oficial de Go), es robusto y ampliamente adoptado. Argon2 es mas moderno pero agrega complejidad sin beneficio practico para la mayoria de apps del registry. Cost por defecto 12 (buen balance velocidad/seguridad). - **JWT con HMAC-SHA256 sin libreria externa:** implementacion manual con `crypto/hmac` + `crypto/sha256` + `encoding/base64`. Solo soporta HS256 — suficiente para apps single-server. Si se necesita RS256 (multiples servicios verificando), se crea una funcion separada en el futuro. - **Sesiones en SQLite:** coherente con la filosofia del registry (SQLite en todas partes). La tabla `sessions` se crea en la BD de la app, no en registry.db. `CREATE TABLE IF NOT EXISTS` evita setup manual. - **OAuth2 sin `golang.org/x/oauth2`:** las tres operaciones (auth_url, exchange, refresh) son suficientemente simples para implementar con `net/http` y `encoding/json`. Evita una dependencia mas y mantiene el control total del flujo. - **RBAC como datos puros:** roles y permisos son slices pasados como argumento, no leidos de BD. Cada app decide donde los almacena (hardcoded, JSON, SQLite). `rbac_check` es pura — la unica funcion pura del modulo junto con `oauth2_auth_url`. - **Claims en context con key privada:** `jwt_middleware` inyecta claims en `r.Context()` usando un tipo no exportado como key (`type contextKey struct{}`) para evitar colisiones. `rbac_middleware` las extrae del mismo context. ## Prerequisitos - **0009 (HTTP Server Foundation):** tipo `Middleware`, funciones `http_middleware_chain`, `http_error_response`, `http_json_response`, `http_parse_body`. Los middlewares de auth (`jwt_middleware`, `rbac_middleware`) retornan `Middleware` y usan los helpers de response para errores 401/403. ## Riesgos - **Seguridad de la implementacion JWT manual:** Mitigado manteniendo el scope en HS256, usando `hmac.Equal` para comparacion constant-time, y validando siempre `exp`. Documentar en cada `.md` que NO es apto para escenarios multi-servicio donde se necesita RSA/ECDSA. - **Almacenamiento de secrets:** Las funciones reciben el secret como parametro (`string`), no lo leen de env ni de archivos. Es responsabilidad de la app obtener el secret de forma segura (env var, `pass_get`, etc.). Documentar este patron. - **Token en header vs cookie:** `jwt_middleware` solo lee `Authorization: Bearer`. Para apps que necesiten cookies (frontend SSR), se crearia un middleware separado en el futuro. No mezclar ambos patrones en la misma funcion. - **SQL injection en sesiones:** Mitigado usando prepared statements con `?` placeholders en todas las queries de sesion. Nunca concatenar strings. - **Session fixation:** `session_create` genera tokens con `crypto/rand` (32 bytes = 256 bits de entropia), haciendo inviable la prediccion o fijacion de tokens. - **bcrypt timing attacks:** `bcrypt.CompareHashAndPassword` ya es constant-time internamente. No se necesita proteccion adicional.