diff --git a/air.toml b/air.toml index 5de0449c..d1ed6df9 100644 --- a/air.toml +++ b/air.toml @@ -2,7 +2,7 @@ root = "/tinyauth" tmp_dir = "tmp" [build] -pre_cmd = ["mkdir -p internal/assets/dist", "mkdir -p /data", "echo 'backend running' > internal/assets/dist/index.html"] +pre_cmd = ["mkdir -p internal/assets/dist", "mkdir -p /data"] # "echo 'backend running' > internal/assets/dist/index.html" cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ./cmd/tinyauth" bin = "tmp/tinyauth" full_bin = "dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue --check-go-version=false" diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index c39a0fb6..288054dd 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -36,12 +36,13 @@ const iconMap: Record = { }; export const LoginPage = () => { - const { isLoggedIn } = useUserContext(); + const { isLoggedIn, tailscaleNodeName } = useUserContext(); const { providers, title, oauthAutoRedirect } = useAppContext(); const { search } = useLocation(); const { t } = useTranslation(); const [showRedirectButton, setShowRedirectButton] = useState(false); + const [useTailscale, setUseTailscale] = useState(tailscaleNodeName !== ""); const hasAutoRedirectedRef = useRef(false); @@ -148,6 +149,32 @@ export const LoginPage = () => { }, }); + const { mutate: tailscaleMutate, isPending: tailscaleIsPending } = + useMutation({ + mutationFn: () => axios.post("/api/user/tailscale"), + mutationKey: ["tailscale"], + onSuccess: () => { + toast.success("Logged in", { + description: t("Tailscale session confirmed"), + }); + + redirectTimer.current = window.setTimeout(() => { + if (oidcParams.isOidc) { + window.location.replace(`/authorize?${oidcParams.compiled}`); + return; + } + window.location.replace( + `/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`, + ); + }, 500); + }, + onError: () => { + toast.error("Failed to login", { + description: "Failed to authenticate with Tailscale.", + }); + }, + }); + useEffect(() => { if ( !isLoggedIn && @@ -228,6 +255,47 @@ export const LoginPage = () => { ); } + + if (useTailscale) { + return ( + + + + + Tinyauth x Tailscale + + + +
+ We detected that you are accessing Tinyauth from an authorized + Tailscale device. Would you like to continue with your Tailscale + credentials? +
+
+ Machine Name: {tailscaleNodeName} +
+
+ + + + +
+ ); + } + return ( diff --git a/frontend/src/schemas/user-context-schema.ts b/frontend/src/schemas/user-context-schema.ts index e7e057ac..0c4b24c6 100644 --- a/frontend/src/schemas/user-context-schema.ts +++ b/frontend/src/schemas/user-context-schema.ts @@ -9,6 +9,7 @@ export const userContextSchema = z.object({ oauth: z.boolean(), totpPending: z.boolean(), oauthName: z.string(), + tailscaleNodeName: z.string(), }); export type UserContextSchema = z.infer; diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index da53303b..4febb48c 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -11,16 +11,17 @@ import ( ) type UserContextResponse struct { - Status int `json:"status"` - Message string `json:"message"` - IsLoggedIn bool `json:"isLoggedIn"` - Username string `json:"username"` - Name string `json:"name"` - Email string `json:"email"` - Provider string `json:"provider"` - OAuth bool `json:"oauth"` - TotpPending bool `json:"totpPending"` - OAuthName string `json:"oauthName"` + Status int `json:"status"` + Message string `json:"message"` + IsLoggedIn bool `json:"isLoggedIn"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + Provider string `json:"provider"` + OAuth bool `json:"oauth"` + TotpPending bool `json:"totpPending"` + OAuthName string `json:"oauthName"` + TailscaleNodeName string `json:"tailscaleNodeName"` } type AppContextResponse struct { @@ -91,6 +92,10 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { OAuthName: context.OAuthName, } + if context.Tailscale != nil { + userContext.TailscaleNodeName = context.Tailscale.NodeName + } + if err != nil { tlog.App.Debug().Err(err).Msg("No user context found in request") userContext.Status = 401 diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 187b33b9..a0d665cd 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -46,6 +46,7 @@ func (controller *UserController) SetupRoutes() { userGroup.POST("/login", controller.loginHandler) userGroup.POST("/logout", controller.logoutHandler) userGroup.POST("/totp", controller.totpHandler) + userGroup.POST("/tailscale", controller.tailscaleHandler) } func (controller *UserController) loginHandler(c *gin.Context) { @@ -309,3 +310,50 @@ func (controller *UserController) totpHandler(c *gin.Context) { "message": "Login successful", }) } + +func (controller *UserController) tailscaleHandler(c *gin.Context) { + context, err := utils.GetContext(c) + + if err != nil { + tlog.App.Error().Err(err).Msg("Failed to get user context") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + + if context.Tailscale == nil { + tlog.App.Warn().Msg("Tailscale session requested but Tailscale device not found") + c.JSON(404, gin.H{ + "status": 404, + "message": "Not Found", + }) + return + } + + sessionCookie := repository.Session{ + Username: context.Tailscale.LoginName, + Name: context.Tailscale.DisplayName, + Email: context.Tailscale.LoginName, + Provider: "tailscale", + } + + tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie") + + err = controller.auth.CreateSessionCookie(c, &sessionCookie) + + if err != nil { + tlog.App.Error().Err(err).Msg("Failed to create session cookie") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + + c.JSON(200, gin.H{ + "status": 200, + "message": "Login successful", + }) +} diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index e187afd0..35c8cec5 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -63,6 +63,8 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { return } + tlog.App.Trace().Interface("cookies", c.Request.Cookies()).Msg("cookies") + cookie, err := m.auth.GetSessionCookie(c) if err != nil { @@ -134,6 +136,18 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { c.Set("context", &ctx) c.Next() return + case "tailscale": + m.auth.RefreshSessionCookie(c) + ctx := m.addTailscaleContext(c, config.UserContext{ + Username: cookie.Username, + Name: cookie.Name, + Email: cookie.Email, + Provider: cookie.Provider, + IsLoggedIn: true, + }) + c.Set("context", &ctx) + c.Next() + return default: _, exists := m.broker.GetService(cookie.Provider) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 1d0d74d3..d8ef347a 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -327,6 +327,16 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *repository.Se return err } + if data.Provider == "tailscale" { + // TODO: use domain from tailscale to set cookie, this is mostly a hack for now + tsCookieDomain, err := utils.GetCookieDomain(fmt.Sprintf("https://%s", c.Request.Host)) + if err != nil { + return err + } + c.SetCookie(auth.config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", tsCookieDomain), auth.config.SecureCookie, true) + return nil + } + c.SetCookie(auth.config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true) return nil